From 334bc5cd04bb4cea923d7bca3906c0c5ad386df6 Mon Sep 17 00:00:00 2001 From: cproudlock Date: Sun, 30 Aug 2026 08:46:14 -0400 Subject: [PATCH 01/16] Add push and promote scripts for the wj branch wj holds this site's work; main is the product every other site sees and the publication mirror exports. Two scripts, one for each direction. tools/git-push.sh pushes a branch without leaving the token behind. The way this was being done by hand - rewrite remote.origin.url to embed the token, push, rewrite it back - writes the token into .git/config in plaintext on every push, and leaves it there if the push fails or the terminal closes mid-way, where `git remote -v` will happily print it. Putting it in the URL on the command line is no better: argv is visible in `ps` to every user on the box. The token now goes in through GIT_ASKPASS, so it never reaches the config or the process list and there is nothing to clean up when the script dies. tools/promote-to-main.sh cherry-picks NAMED commits onto main, rather than merging the branch. Merging takes the site-specific work along with the product work, which is how a site branch quietly becomes the product; naming the commits keeps that decision explicit and per change. It runs main's gates after the pick and before any push, and does not push at all - it prints what landed and leaves that to a human. The ADR-015 site-literal check is the one that earns its place: it catches a hostname or share path riding along in a commit that looked generic, which is the failure that makes a site branch dangerous rather than merely awkward. It caught this very commit, which named the site in a comment. Both refuse a dirty tree. Pushing one pushes the last commit rather than what is in front of you, and cherry-picking from one mixes uncommitted work into the promotion. On failure the promote script aborts the pick and returns you to the branch you started on, with main untouched. (cherry picked from commit 6d9954c6f9932d0d137f9d52d827b6b35f304abf) --- tools/git-push.sh | 109 +++++++++++++++++++++++++++++ tools/promote-to-main.sh | 145 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 254 insertions(+) create mode 100755 tools/git-push.sh create mode 100755 tools/promote-to-main.sh diff --git a/tools/git-push.sh b/tools/git-push.sh new file mode 100755 index 00000000..778840be --- /dev/null +++ b/tools/git-push.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# +# Push a branch to the internal git server, without leaving the token behind. +# +# tools/git-push.sh push the current branch +# tools/git-push.sh wj push a named branch +# +# WHY THIS EXISTS +# +# The remote is HTTPS and needs a token. The obvious way to do that - rewrite +# remote.origin.url to include the token, push, rewrite it back - was how this +# was done by hand, and it is worse than it looks: +# +# - the token is written into .git/config, in plaintext, on every push +# - if the push fails, or the terminal is closed mid-push, it STAYS there +# - `git remote -v` then prints it, and so does anything that logs the remote +# +# Passing it in the URL on the command line is no better: the whole argv is +# visible in `ps` to every user on the box for the length of the push. +# +# So the token goes in through GIT_ASKPASS instead. Git asks a helper program +# for the password; the helper prints it on stdout and exits. It never touches +# the config, never appears in argv, and there is nothing to clean up if this +# script dies halfway. +set -euo pipefail + +SECRETS="${SHOPDB_SECRETS:-/home/camp/projects/windows/shopdb/secrets.md}" +REMOTE_HOST="gitea.proudtech.net" +REMOTE_PATH="ge-aerospace/shopdb-flask.git" +USERNAME="cproudlock" + +BRANCH="${1:-$(git rev-parse --abbrev-ref HEAD)}" + +if [ "$BRANCH" = "HEAD" ]; then + echo "ERROR: detached HEAD. Check out a branch, or name one." >&2 + exit 1 +fi + +if ! git show-ref --verify --quiet "refs/heads/$BRANCH"; then + echo "ERROR: no such local branch: $BRANCH" >&2 + exit 1 +fi + +# Refuse to push a branch that is not what is in front of you. Pushing a stale +# branch while your work sits uncommitted looks like a successful push and is +# the kind of thing found out much later. +if [ -n "$(git status --porcelain)" ]; then + 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 1 +fi + +if [ ! -r "$SECRETS" ]; then + echo "ERROR: cannot read $SECRETS" >&2 + echo "Set SHOPDB_SECRETS to point at it." >&2 + exit 1 +fi + +# The proudtech.net section, not the localhost one above it. +TOKEN="$(sed -n '/Gitea API (proudtech.net)/,/^## /p' "$SECRETS" \ + | grep -i 'token' \ + | sed 's/.*`\(.*\)`.*/\1/' \ + | head -1)" + +if [ -z "$TOKEN" ]; then + echo "ERROR: no token found in the proudtech.net section of $SECRETS" >&2 + exit 1 +fi + +# The askpass helper. Written to a file only this user can read, in a directory +# that is removed on exit however this script ends. +WORKDIR="$(mktemp -d)" +trap 'rm -rf "$WORKDIR"' EXIT INT TERM + +ASKPASS="$WORKDIR/askpass.sh" +umask 077 +cat > "$ASKPASS" < itself. +git update-ref "refs/remotes/origin/$BRANCH" "refs/heads/$BRANCH" + +echo "Pushed $BRANCH." diff --git a/tools/promote-to-main.sh b/tools/promote-to-main.sh new file mode 100755 index 00000000..aef79a66 --- /dev/null +++ b/tools/promote-to-main.sh @@ -0,0 +1,145 @@ +#!/usr/bin/env bash +# +# Take specific commits from wj to main. +# +# tools/promote-to-main.sh [ ...] +# tools/promote-to-main.sh --dry-run ... +# +# Commits are applied in the order given, so give them oldest first. +# +# WHY CHERRY-PICK AND NOT A MERGE +# +# wj holds this site's work. Some of it is product - the rest of the world +# should have it - and some belongs to this site alone. Merging the branch takes +# both; that is how a site branch quietly becomes the product. Naming the +# commits keeps the decision explicit and per change. +# +# WHAT THIS REFUSES TO DO +# +# main is what every other site sees, and what the publication mirror exports. +# So this runs main's gates AFTER the pick and BEFORE the push, and leaves the +# pick unpushed if any of them fail. The ADR-015 check matters most: it is what +# catches a hostname, share path or subnet riding along in a commit that looked +# generic. That check is the whole reason a site branch is survivable. +# +# It does NOT push automatically. Read what it says, then run tools/git-push.sh +# main yourself. +set -euo pipefail + +cd "$(git rev-parse --show-toplevel)" + +DRYRUN=0 +if [ "${1:-}" = "--dry-run" ]; then + DRYRUN=1 + shift +fi + +if [ "$#" -eq 0 ]; then + echo "usage: tools/promote-to-main.sh [--dry-run] [ ...]" >&2 + echo >&2 + echo "Give commits oldest first. Find them with:" >&2 + echo " git log --oneline main..wj" >&2 + exit 1 +fi + +STARTING_BRANCH="$(git rev-parse --abbrev-ref HEAD)" + +if [ -n "$(git status --porcelain)" ]; then + 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 1 +fi + +# Resolve and describe first, so a typo is caught before anything moves. +echo "About to promote onto main, in this order:" +for ref in "$@"; do + if ! git rev-parse --verify --quiet "$ref^{commit}" >/dev/null; then + echo "ERROR: not a commit: $ref" >&2 + exit 1 + fi + echo " $(git log --oneline -1 "$ref")" +done +echo + +if [ "$DRYRUN" -eq 1 ]; then + echo "--dry-run: files each commit touches, so you can see what reaches main." + for ref in "$@"; do + echo + echo "--- $(git log --oneline -1 "$ref") ---" + git show --stat --oneline "$ref" | tail -n +2 + done + echo + echo "Nothing was changed." + exit 0 +fi + +# Put us back where we started, whatever happens next. +restore() { + local code=$? + if [ "$code" -ne 0 ]; then + echo + echo "Promotion did NOT complete." + if git rev-parse --verify --quiet CHERRY_PICK_HEAD >/dev/null 2>&1; then + echo "Aborting the in-progress cherry-pick ..." + git cherry-pick --abort || true + fi + local now + now="$(git rev-parse --abbrev-ref HEAD)" + if [ "$now" != "$STARTING_BRANCH" ]; then + git checkout "$STARTING_BRANCH" >/dev/null 2>&1 || true + fi + echo "Back on $STARTING_BRANCH. main is unchanged and nothing was pushed." + fi +} +trap restore EXIT + +git checkout main +MAIN_BEFORE="$(git rev-parse HEAD)" + +for ref in "$@"; do + echo + echo "==> picking $(git log --oneline -1 "$ref")" + if ! git cherry-pick -x "$ref"; then + echo + echo "That commit does not apply cleanly to main." + echo "Resolve it by hand if it is worth having:" + echo " git status # see the conflict" + echo " git cherry-pick --abort # or walk away" + exit 1 + fi +done + +echo +echo "==> main gates" + +echo "--- naming, style, site literals (ADR-015) ---" +bash scripts/check-naming-and-style.sh + +echo "--- generated project map ---" +if ! venv/bin/python scripts/gen_project_map.py --check >/dev/null 2>&1; then + echo "Project map is stale; regenerating." + venv/bin/python scripts/gen_project_map.py + git add docs/PROJECT-MAP.md + git commit -q -m "Regenerate the project map after promotion" +fi + +echo "--- python suite ---" +venv/bin/python -m pytest -q + +echo "--- frontend ---" +( cd frontend && npm test --silent ) + +trap - EXIT + +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.sh main" +echo +echo "You are on main. Return to your branch with:" +echo " git checkout $STARTING_BRANCH" From 28aee355f991fadf7b3824f81fa9334d7c847e7f Mon Sep 17 00:00:00 2001 From: cproudlock Date: Sun, 30 Aug 2026 08:48:27 -0400 Subject: [PATCH 02/16] Add Windows versions of the push and promote scripts Same two operations as the bash pair, for driving git from Windows rather than from the development box. CREDENTIALS ARE HANDLED DIFFERENTLY, ON PURPOSE The bash version reads a token out of secrets.md and feeds it to git through GIT_ASKPASS, because that box has no credential store. Windows has one: Git for Windows installs Git Credential Manager and enables it by default, so `git push` prompts once and Windows Credential Manager keeps it. So the token handling is deliberately NOT ported. Copying a token into a script, an environment variable or a remote URL on a machine that already has a credential store is strictly worse than the thing it would replace, and the comment in the file says so, because "make it match the other script" is exactly the tidying somebody would otherwise do later. THE PROMOTE SCRIPT REFUSES TO RUN WITHOUT BASH scripts/check-naming-and-style.sh is a shell script, and it carries the ADR-015 site-literal check - the one gate that makes a site branch survivable rather than a slow leak into the product. Skipping it on a machine without bash would defeat the entire point of the script, so it stops and says how to fix it instead of promoting without it. CRLF .gitattributes already pinned *.sh to LF so Git Bash can run them; *.cmd is now pinned to CRLF for the same class of reason in the other direction. cmd.exe parses a lone LF inconsistently around labels and goto, so an LF-only batch file can run its happy path and then jump nowhere on the error path - which in a promote script is the path that matters. (cherry picked from commit 49a898fc5a4164a082e87c29f141157842e2585a) --- .gitattributes | 6 ++ tools/git-push.cmd | 63 ++++++++++++++++ tools/promote-to-main.cmd | 154 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 223 insertions(+) create mode 100644 tools/git-push.cmd create mode 100644 tools/promote-to-main.cmd 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 From e838aaf737283219e7c9b37a377b902f0811e0cf Mon Sep 17 00:00:00 2001 From: cproudlock Date: Sun, 30 Aug 2026 08:50:32 -0400 Subject: [PATCH 03/16] Say which wj commits belong on main The scripts could move a commit to main but nothing said which commits should go, and that decision does not survive being deferred: months later it is a list of subject lines and a guess. So it is recorded at commit time, as a trailer: Scope: product the rest of the world should have it Scope: site this site alone, keep it here tools/wj-status.sh reads them and prints main..wj in three groups, with the promote command for the ones marked product. The path fallback is deliberately weak. Under ADR-015 the only site-specific home in the tree is scripts/site_imports// - everything else is meant to be product, with settings and seed data carrying what differs between sites. So "touches only site_imports" is the one confident site verdict paths can give, and everything else is reported UNMARKED rather than guessed into a bucket. A tool that quietly sorted commits by directory would be wrong often enough to stop being read. The unmarked message says which way to lean, because the failure mode here is not promoting something site-specific by accident - the ADR-015 gate catches that on the way in. It is product work sitting on wj forever because nobody decided, which is how the wilm branch became a port rather than a merge. Scope: product (cherry picked from commit 0400a587869eba4022c72212778da14b680ec4ed) --- tools/wj-status.sh | 133 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100755 tools/wj-status.sh diff --git a/tools/wj-status.sh b/tools/wj-status.sh new file mode 100755 index 00000000..d133cd53 --- /dev/null +++ b/tools/wj-status.sh @@ -0,0 +1,133 @@ +#!/usr/bin/env bash +# +# What is on wj that is not on main, and where each commit belongs. +# +# tools/wj-status.sh classify everything unpromoted +# tools/wj-status.sh --product print just the promotable SHAs +# +# The second form is meant to be read by a person and then pasted, oldest +# first: +# +# tools/promote-to-main.sh $(tools/wj-status.sh --product) +# +# HOW A COMMIT IS CLASSIFIED +# +# By a trailer you write at commit time: +# +# Scope: product belongs on main, the rest of the world should have it +# Scope: site belongs to this site alone, keep it on wj +# +# Intent is the only thing that can actually decide this, and you have it when +# you write the commit, not months later when you are staring at a list of +# subject lines trying to remember. +# +# Where there is no trailer this falls back to paths, but the fallback is +# deliberately weak, and says so. Under ADR-015 the only site-specific home in +# the tree is scripts/site_imports// - everything else is meant to be +# product with settings and seed data carrying the differences. So "touches +# only site_imports" is the one confident site verdict available, and anything +# else is reported as UNMARKED rather than guessed into a bucket. +set -euo pipefail + +cd "$(git rev-parse --show-toplevel)" + +MODE="${1:-report}" + +if ! git show-ref --verify --quiet refs/heads/main; then + echo "ERROR: no main branch here." >&2 + exit 1 +fi + +RANGE="main..wj" +if ! git rev-parse --verify --quiet refs/heads/wj >/dev/null; then + echo "ERROR: no wj branch here." >&2 + exit 1 +fi + +COMMITS="$(git rev-list --reverse "$RANGE")" + +if [ -z "$COMMITS" ]; then + echo "Nothing on wj that main does not have." + exit 0 +fi + +classify() { + local sha="$1" + local scope + scope="$(git log -1 --format='%(trailers:key=Scope,valueonly)' "$sha" | tr -d '[:space:]')" + case "$(printf '%s' "$scope" | tr '[:upper:]' '[:lower:]')" in + product) echo product; return ;; + site) echo site; return ;; + esac + + # No trailer. The only confident verdict from paths alone. + local files outside + files="$(git show --pretty=format: --name-only "$sha" | sed '/^$/d')" + outside="$(printf '%s\n' "$files" | grep -v '^scripts/site_imports/' || true)" + if [ -n "$files" ] && [ -z "$outside" ]; then + echo site-by-path + else + echo unmarked + fi +} + +if [ "$MODE" = "--product" ]; then + for sha in $COMMITS; do + if [ "$(classify "$sha")" = "product" ]; then + printf '%s ' "$sha" + fi + done + echo + exit 0 +fi + +product_shas="" +unmarked_count=0 + +echo "On wj, not on main - oldest first" +echo + +for sha in $COMMITS; do + verdict="$(classify "$sha")" + subject="$(git log -1 --format='%h %s' "$sha")" + case "$verdict" in + product) + echo " [PRODUCT ] $subject" + product_shas="$product_shas $sha" + ;; + site) + echo " [SITE ] $subject" + ;; + site-by-path) + echo " [site? ] $subject" + echo " touches only scripts/site_imports/ - looks site-only, not marked" + ;; + *) + echo " [UNMARKED] $subject" + unmarked_count=$((unmarked_count + 1)) + ;; + esac +done + +echo +if [ -n "$product_shas" ]; then + echo "Marked for main:" + echo " tools/promote-to-main.sh$product_shas" + echo +fi + +if [ "$unmarked_count" -gt 0 ]; then + echo "$unmarked_count commit(s) carry no Scope trailer." + echo + echo "That is not a failure - it just means nobody decided yet. Under ADR-015" + echo "most work is product: site differences are meant to live in settings and" + echo "seed data, not in a branch. So an unmarked commit is more likely to" + echo "belong on main than not, and leaving it unmarked is how it ends up" + echo "stranded here." + echo + echo "Mark future ones as you go:" + echo " git commit -m \"...\" -m \"Scope: product\"" + echo + echo "Or decide now and promote the ones that belong:" + echo " tools/promote-to-main.sh --dry-run " +fi From 1cc725d4a769fc7b98322c09514246eb509bd948 Mon Sep 17 00:00:00 2001 From: cproudlock Date: Sun, 30 Aug 2026 09:00:09 -0400 Subject: [PATCH 04/16] Write down which branch work belongs on The convention existed only in the scripts' comments and in one conversation, which is not where somebody joining looks. CONTRIBUTING is. Records the two branches, the Scope trailer, the promote flow, and the point that most work is product: ADR-015 puts site differences in settings, seed data or scripts/site_imports//, so a site branch is about timing rather than locality. Also names the failure worth watching. Promoting something site-specific by accident is caught by the ADR-015 gate on the way into main. The real risk is product work stranded on a site branch because nobody decided, which is what turned wilm from a merge into a port. Scope: product (cherry picked from commit 1d4ac11572ed9e247d28fad8ebe019f993c4cf94) --- CONTRIBUTING.md | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index db0f47aa..654127ef 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -151,6 +151,51 @@ Tests are required for: - New plugin hooks (contract test) - Schema migrations (before/after data integrity) +## Branches: what belongs on main, and what does not + +Two branches. `main` is the product every site runs and the publication mirror +exports. `` branches - currently `wj` - hold work that is not ready for +other sites to see. + +**Most work is product.** ADR-015 gives site differences three homes on `main`: +a setting with a neutral default, seed data, or a site-namespaced directory +(`scripts/site_imports//`, which the style gate exempts). That is the only +site-specific home in the tree, so a site branch is about TIMING, not locality: +things that are not ready yet, rather than things that are permanently ours. + +Say which at commit time, with a trailer, because the decision does not survive +being deferred - months later it is a list of subject lines and a guess: + +```bash +git commit -m "Add the thing" -m "Scope: product" # main should have it +git commit -m "Add the thing" -m "Scope: site" # this site alone +``` + +Then: + +```bash +tools/wj-status.sh # what is unpromoted, and where it belongs +tools/promote-to-main.sh --dry-run # what files would reach main +tools/promote-to-main.sh [ ...] # oldest first; runs main's gates +tools/git-push.sh main # you push, deliberately +``` + +Windows equivalents are `tools\git-push.cmd` and `tools\promote-to-main.cmd`. + +Promotion cherry-picks NAMED commits rather than merging the branch. Merging +takes the site work along with the product work, which is how a site branch +quietly becomes the product. + +**The failure to watch for is not what you think.** Promoting something +site-specific by accident is caught by the ADR-015 gate on the way into `main`. +The real risk is product work sitting on a site branch forever because nobody +decided - which is how the `wilm` branch stopped being a merge and became a +port, costing a MySQL dialect fix, a contract violation and a plugin install to +bring back. + +So rebase the site branch on `main` often, and promote as you go rather than in +one batch at the end. + ## Code review checklist - [ ] No underscores in table or column names From d8a2bfeb29168ca2d94ab2390fa9456e92f5570f Mon Sep 17 00:00:00 2001 From: cproudlock Date: Sun, 30 Aug 2026 09:03:25 -0400 Subject: [PATCH 05/16] Publish the branch you are on, not always main export-github.sh syncs the WORKING TREE, not a branch, and then committed whatever it found onto the publication repo's main and printed "git push origin main". So running it while checked out on a site branch published that site's work AS THE PRODUCT, to the repository every other site consumes, and said nothing. The branch model made that reachable in one wrong command, from a checkout that looks completely normal. It now reads the current branch, mirrors it in the publication repo (creating it from main the first time, as in the dev repo), bundles that branch, and prints work-PC instructions naming it. Publishing anything other than main also prints a reminder that other sites read main and these commits are not there yet. The publication checkout uses --force, which is safe only because that tree is a mirror: the rsync has already replaced it, so there is nothing local to lose. It would not be safe anywhere else. Also cleans up the publication repo: origin pointed at a bundle in a scratchpad from a session that ended long ago, and two orphaned remote-tracking refs (github/main, wilm/review-fixes) survived the removal of their remotes. None of it broke the export, which never pushes, but a `git fetch` in there failed with a confusing path. Scope: product (cherry picked from commit 81df9607e79e3e82ae3d53d6c2c423eade092cbc) --- tools/export-github.sh | 50 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 46 insertions(+), 4 deletions(-) diff --git a/tools/export-github.sh b/tools/export-github.sh index 5c0bfadc..0a99e084 100755 --- a/tools/export-github.sh +++ b/tools/export-github.sh @@ -29,6 +29,22 @@ done [ -n "$MSG" ] || { echo "usage: $0 [--dist] \"commit message\""; exit 1; } [ -d "$PUB/.git" ] || { echo "publication repo missing at $PUB"; exit 1; } +# --- 0. which branch is being published ------------------------------------- +# +# This syncs the WORKING TREE, not a branch, and it used to commit whatever it +# found onto the publication repo's main and print "git push origin main". So +# running it while checked out on a site branch published that site's work AS +# THE PRODUCT, to the repository every other site consumes, without saying a +# word about it. +# +# The publication repo now mirrors the branch you are actually on. +BRANCH="$(git -C "$WORK" rev-parse --abbrev-ref HEAD)" +if [ "$BRANCH" = "HEAD" ]; then + echo "detached HEAD in $WORK - check out a branch before exporting" >&2 + exit 1 +fi +echo "==> publishing branch: $BRANCH" + # --- 1. sync the tree (working -> pub), minus everything never published --- # # NOTE the deploy/windows/installer/bundle exclude below. That is ~100MB of build @@ -141,6 +157,19 @@ fi cd "$PUB" +# Mirror the branch. The rsync above has already overwritten the tree, so this +# has to happen with the working tree ignored - checkout would otherwise refuse +# or clobber. --force is safe here precisely because the tree is a mirror: it +# holds nothing that is not about to be replaced from $WORK. +if git show-ref --verify --quiet "refs/heads/$BRANCH"; then + git checkout --force "$BRANCH" >/dev/null +else + # A new site branch starts from main, the same way it does in the dev repo, + # so its history is the product's history plus whatever it adds. + echo "==> publication repo: creating branch $BRANCH from main" + git checkout --force -b "$BRANCH" main >/dev/null +fi + # --- 2. re-apply the publication wording (idempotent) --- # docs/ lives only in the wiki on the GitHub side. # NOTE the `|| true` on each scrub grep below. grep exits 1 when it matches @@ -205,7 +234,8 @@ fi git log --oneline -3 # --- 5. bundle for transfer (full bundle: stateless, fetch takes only new) --- -git bundle create "$OUT/shopdb-flask-pub.bundle" HEAD main --tags +# Carries the branch that was published, not a hardcoded main. +git bundle create "$OUT/shopdb-flask-pub.bundle" HEAD "$BRANCH" --tags git bundle verify "$OUT/shopdb-flask-pub.bundle" >/dev/null echo "bundle: $OUT/shopdb-flask-pub.bundle" @@ -222,10 +252,22 @@ if [ "$BUILD_DIST" = 1 ]; then done fi -cat <<'EOF' +cat <\shopdb-flask-pub.bundle main + git checkout $BRANCH + git fetch \shopdb-flask-pub.bundle $BRANCH git merge --ff-only FETCH_HEAD - git push origin main + git push origin $BRANCH EOF + +if [ "$BRANCH" != "main" ]; then + cat < ... +EOF +fi From 442db6a664bdddd32bf725bc5121c737951d450d Mon Sep 17 00:00:00 2001 From: cproudlock Date: Sun, 30 Aug 2026 09:04:56 -0400 Subject: [PATCH 06/16] Mirror the publication branch BEFORE the sync, not after The branch checkout landed after the rsync, where git checkout --force reverts the tree to the branch's committed state and discards everything rsync had just written. The export then found no changes and published nothing, while still printing a bundle path and looking like it had worked. A silent no-op is the worst shape for this: the next person believes the export ran. Scope: product (cherry picked from commit 06c584240ee3439d90f0968afb5c2e2370bbdb74) --- tools/export-github.sh | 36 +++++++++++++++++++++++------------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/tools/export-github.sh b/tools/export-github.sh index 0a99e084..cbcb788f 100755 --- a/tools/export-github.sh +++ b/tools/export-github.sh @@ -98,6 +98,29 @@ done # Keep flags on their own lines. A comment inside this backslash-continued # command is not a comment - bash hands the text to rsync as arguments, and it # dies with a usage dump rather than anything that names the real problem. +# Mirror the branch in the publication repo BEFORE the sync. +# +# Order matters and got this wrong once: done after the rsync, `git checkout +# --force` reverts the tree to the branch's committed state and throws away +# everything rsync just wrote, so the export reports "no changes" and publishes +# nothing. It is a silent no-op, which is the worst shape for this failure - +# the script still prints a bundle path and looks like it worked. +# +# --force is needed because the tree may hold leftovers from the previously +# published branch, and safe because this tree is a mirror: the rsync below +# replaces it wholesale from $WORK. +( + cd "$PUB" + if git show-ref --verify --quiet "refs/heads/$BRANCH"; then + git checkout --force "$BRANCH" >/dev/null + else + # A new site branch starts from main, as it does in the dev repo, so its + # history is the product's plus whatever it adds. + echo "==> publication repo: creating branch $BRANCH from main" + git checkout --force -b "$BRANCH" main >/dev/null + fi +) + rsync -a --delete \ --exclude '.git' \ --exclude '.gitea' \ @@ -157,19 +180,6 @@ fi cd "$PUB" -# Mirror the branch. The rsync above has already overwritten the tree, so this -# has to happen with the working tree ignored - checkout would otherwise refuse -# or clobber. --force is safe here precisely because the tree is a mirror: it -# holds nothing that is not about to be replaced from $WORK. -if git show-ref --verify --quiet "refs/heads/$BRANCH"; then - git checkout --force "$BRANCH" >/dev/null -else - # A new site branch starts from main, the same way it does in the dev repo, - # so its history is the product's history plus whatever it adds. - echo "==> publication repo: creating branch $BRANCH from main" - git checkout --force -b "$BRANCH" main >/dev/null -fi - # --- 2. re-apply the publication wording (idempotent) --- # docs/ lives only in the wiki on the GitHub side. # NOTE the `|| true` on each scrub grep below. grep exits 1 when it matches From e65053feeb2e2b063257ec5ed5a720d45a25942e Mon Sep 17 00:00:00 2001 From: cproudlock Date: Sun, 30 Aug 2026 09:06:56 -0400 Subject: [PATCH 07/16] One bundle per branch, named after it The export wrote a single shopdb-flask-pub.bundle whatever it published. That was fine while only main was ever published, and became a trap the moment it was not: exporting a site branch silently overwrote the bundle holding main's work, and the work PC's documented "git fetch main" then failed because that ref was no longer in the file. It had already happened here - two wj exports overwrote a main bundle from the same morning. The failure is at least loud on the work PC rather than pushing the wrong branch, but the main bundle has to be regenerated. Now shopdb-flask-pub-.bundle. Two branches cannot share one filename. Scope: product (cherry picked from commit 1311dea1132be23da2cca9b3d04191d16e6f461b) --- tools/export-github.sh | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/tools/export-github.sh b/tools/export-github.sh index cbcb788f..907408e8 100755 --- a/tools/export-github.sh +++ b/tools/export-github.sh @@ -244,10 +244,19 @@ fi git log --oneline -3 # --- 5. bundle for transfer (full bundle: stateless, fetch takes only new) --- -# Carries the branch that was published, not a hardcoded main. -git bundle create "$OUT/shopdb-flask-pub.bundle" HEAD "$BRANCH" --tags -git bundle verify "$OUT/shopdb-flask-pub.bundle" >/dev/null -echo "bundle: $OUT/shopdb-flask-pub.bundle" +# +# ONE BUNDLE PER BRANCH, named after it. +# +# This wrote a single shopdb-flask-pub.bundle whatever it published, which was +# fine while only main was ever published and became a trap the moment it was +# not: exporting a site branch silently overwrote the bundle holding main's +# work, and the work PC's documented `git fetch main` then failed +# because that ref was no longer in the file. Two branches cannot share one +# filename. +BUNDLE="$OUT/shopdb-flask-pub-$BRANCH.bundle" +git bundle create "$BUNDLE" HEAD "$BRANCH" --tags +git bundle verify "$BUNDLE" >/dev/null +echo "bundle: $BUNDLE" # --- 6. optional per-instance dists (both bases: /ops = dev, /shopdb = prod) --- # Two instances run on the box; each needs its own base-path build. Always @@ -266,7 +275,7 @@ cat <\shopdb-flask-pub.bundle $BRANCH + git fetch \shopdb-flask-pub-$BRANCH.bundle $BRANCH git merge --ff-only FETCH_HEAD git push origin $BRANCH EOF From cd86c27969f10a155e274a3180191177a7b8987d Mon Sep 17 00:00:00 2001 From: cproudlock Date: Sun, 30 Aug 2026 09:52:29 -0400 Subject: [PATCH 08/16] Show a guest what they can see, not a blank page A guest got a heading and nothing else. THE STRUCTURAL FAULT WAS MINE Moving the Estate panel into DashboardCards' rail slot put it behind that component's root v-if, which was `liveCards.length > 0`. A guest is permitted zero cards, so the whole component disappeared and took the slot with it. Before that move the tiles were top-level and always rendered. The wrapper no longer vanishes with the cards. TWO LIES THE EMPTY STATE WAS TELLING "Nothing needs attention" is now shown only to somebody who can see the checks. A reader permitted none of them is not looking at a healthy fleet, they are looking at nothing, and saying otherwise is the same fault as reporting Low Supplies 0 while the monitoring is down. The count of PERMITTED cards is now tracked separately from the count that currently hold rows. The Estate panel no longer renders zeros for a caller who cannot read totals. /api/dashboard/summary needs assets.view, and the failure was swallowed into an empty object that drew a tidy column of zeros - an estate of nothing, stated with confidence. WHAT A GUEST GETS INSTEAD Where they can go: the same permission-filtered navigation the sidebar uses, which the server already filters to the guest role for a signed-out visitor. So it cannot offer a link that dead-ends at the router guard, and this page names no plugin it had to know about. Verified against the running instance - a guest is offered Guides and Tech Tools, matching guides.view and tools.view. ALSO: the measuring tools row totalassets counts five types - machines, PCs, network, printers AND measuring tools - but only four had rows, so the total stopped matching the addends above it wherever gages exist: 610 of visible rows under a stated 729. That is exactly the fault this panel was built to remove, and it looked right here only because this database has no measuring tools. Every counted type now has a row. Frontend 314 passed. (cherry picked from commit 0f2da9beaf3c6c1bde53f6190e8ef2d0cf2d2b8f) --- frontend/src/components/DashboardCards.vue | 20 ++++-- frontend/src/views/Dashboard.vue | 84 ++++++++++++++++++---- 2 files changed, 88 insertions(+), 16 deletions(-) diff --git a/frontend/src/components/DashboardCards.vue b/frontend/src/components/DashboardCards.vue index 4ce82ba4..076b7887 100644 --- a/frontend/src/components/DashboardCards.vue +++ b/frontend/src/components/DashboardCards.vue @@ -1,5 +1,5 @@ @@ -84,21 +119,46 @@ // needed a person put together. A filtered "recently changed" view belongs in // Reports. import DashboardCards from '@/components/DashboardCards.vue' -import { ref, onMounted } from 'vue' +import { ref, computed, onMounted } from 'vue' import { dashboardApi } from '../api' const loading = ref(true) const stats = ref({}) +// Whether the totals actually came back. Swallowing the error and rendering +// stats as {} turned a 403 into a column of confident zeros. +const summaryloaded = ref(false) + +// Where this reader can actually go, for the case where the board has nothing +// for them. Same source as the sidebar, so it is already filtered to what they +// hold - and to what the guest role reaches, when nobody is signed in. +const navitems = ref([]) + +const openable = computed( + // The dashboard itself is not somewhere to go from the dashboard. + () => navitems.value.filter((item) => item.route && item.route !== '/')) onMounted(async () => { try { - const response = await dashboardApi.summary().catch(() => ({ data: { data: {} } })) + const response = await dashboardApi.summary() stats.value = response.data.data || {} + summaryloaded.value = Object.keys(stats.value).length > 0 } catch (error) { - console.error('Dashboard load error:', error) - } finally { - loading.value = false + // A caller without assets.view is restricted, not broken, so this is not + // logged as an error - the panel offers them somewhere to go instead. + summaryloaded.value = false } + + if (!summaryloaded.value) { + // Only fetched when it is needed, so the common case costs nothing. + try { + const response = await dashboardApi.navigation() + navitems.value = response.data.data || [] + } catch (error) { + navitems.value = [] + } + } + + loading.value = false }) From 03804bb9961de0f484dbaf2c1dcf18cffe9bed60 Mon Sep 17 00:00:00 2001 From: cproudlock Date: Sun, 30 Aug 2026 09:53:04 -0400 Subject: [PATCH 09/16] Stop the promote script from deleting itself mid-run promote-to-main.sh checks out main. The tooling lives only on the site branch, so that checkout removes tools/promote-to-main.sh while the script is still running - and bash reads a script lazily, by file offset, so it would carry on executing whatever bytes happen to land there. While manipulating branches. Not theoretical: main does not have tools/ right now, so the first real use would have hit it. Both versions now copy themselves to a temp file and re-exec from there. The bash copy unlinks itself immediately, since the open handle keeps working. The Windows copy has the same hazard for the same reason - cmd.exe re-seeks the batch file after every command - and two extra traps worth naming: the copy runs from %TEMP%, so the repository root has to travel with it rather than being derived from %~dp0, and %errorlevel% inside a parenthesised block is expanded when the block is PARSED, before the call it is meant to report on. Scope: product (cherry picked from commit 6d42110a919f71358b2f9dc5df3c153a26ffc41a) --- tools/promote-to-main.cmd | 25 ++++++++++++++++++++++++- tools/promote-to-main.sh | 21 +++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/tools/promote-to-main.cmd b/tools/promote-to-main.cmd index b141020d..7f96abfd 100644 --- a/tools/promote-to-main.cmd +++ b/tools/promote-to-main.cmd @@ -17,7 +17,30 @@ 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.." +rem RUN FROM A COPY OF OURSELF. +rem +rem This checks out main. The tooling may live only on the site branch, in which +rem case that checkout DELETES this file while cmd.exe is still reading it - and +rem cmd.exe reads a batch file line by line, by file offset, re-seeking after +rem every command. It would carry on reading whatever bytes are at that offset +rem afterwards, while manipulating branches. The bash version has the same +rem hazard and the same fix. +if not "%PROMOTE_SELFCOPY%"=="1" ( + rem The copy runs from %TEMP%, where %~dp0 is the temp directory and not the + rem checkout, so the repository root has to travel with it. + set "PROMOTE_REPO=%~dp0.." + set "PROMOTE_SELFCOPY=1" + copy /y "%~f0" "%TEMP%\promote-to-main-copy.cmd" >nul + call "%TEMP%\promote-to-main-copy.cmd" %* + rem Delayed expansion: a plain %errorlevel% here is expanded when the block is + rem parsed, which is BEFORE the call above has run, so it would always report + rem the code from whatever ran previously. + set "EXITCODE=!errorlevel!" + del /q "%TEMP%\promote-to-main-copy.cmd" >nul 2>&1 + exit /b !EXITCODE! +) + +cd /d "%PROMOTE_REPO%" set "DRYRUN=0" if "%~1"=="--dry-run" ( diff --git a/tools/promote-to-main.sh b/tools/promote-to-main.sh index aef79a66..885ed73c 100755 --- a/tools/promote-to-main.sh +++ b/tools/promote-to-main.sh @@ -26,6 +26,27 @@ # main yourself. set -euo pipefail +# RUN FROM A COPY OF OURSELF. +# +# This script checks out main. The tooling may live only on the site branch, in +# which case that checkout DELETES this file while bash is still reading it - +# and bash reads a script lazily, by offset, so it would carry on executing +# whatever bytes happen to be at that position afterwards. While manipulating +# branches. This is not theoretical: main did not have tools/ when this was +# written, so the very first real use would have hit it. +# +# Copy to a temp file, re-exec from there, and delete the copy immediately. On +# Unix the open file handle keeps working after the unlink, so the running +# script survives whatever git does to the tree. +if [ "${PROMOTE_SELFCOPY:-}" != "1" ]; then + SELF="$(mktemp)" + cat "$0" > "$SELF" + chmod 700 "$SELF" + export PROMOTE_SELFCOPY=1 + exec bash "$SELF" "$@" +fi +rm -f "$0" 2>/dev/null || true + cd "$(git rev-parse --show-toplevel)" DRYRUN=0 From ef962a1bf9233b59438ab293fee7e916799c5a0c Mon Sep 17 00:00:00 2001 From: cproudlock Date: Sun, 30 Aug 2026 09:57:53 -0400 Subject: [PATCH 10/16] Print the first-time path for a new branch on the work PC The work-PC instructions began with 'git checkout ', which fails the first time a branch goes over because it does not exist in that clone yet - exactly the moment somebody follows them most carefully. Nothing to check out, and nothing to merge into either. Now prints both: fetch then checkout -b then push -u the first time, and the checkout/fetch/merge/push cycle afterwards. Scope: product (cherry picked from commit 1fe788a3ed477addf5b8e38cdfc470fd7adf8ea5) --- tools/export-github.sh | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/tools/export-github.sh b/tools/export-github.sh index 907408e8..7c4ca2a5 100755 --- a/tools/export-github.sh +++ b/tools/export-github.sh @@ -274,10 +274,20 @@ fi cat <\shopdb-flask-pub-$BRANCH.bundle $BRANCH - git merge --ff-only FETCH_HEAD - git push origin $BRANCH + + The FIRST time this branch goes over - it does not exist there yet, so + there is nothing to check out and nothing to merge into: + + git fetch \shopdb-flask-pub-$BRANCH.bundle $BRANCH + git checkout -b $BRANCH FETCH_HEAD + git push -u origin $BRANCH + + Every time after that: + + git checkout $BRANCH + git fetch \shopdb-flask-pub-$BRANCH.bundle $BRANCH + git merge --ff-only FETCH_HEAD + git push origin $BRANCH EOF if [ "$BRANCH" != "main" ]; then From 7a8acbbcb53a1079f499c2828cc7e4c90fd2995d Mon Sep 17 00:00:00 2001 From: cproudlock Date: Sun, 30 Aug 2026 11:12:09 -0400 Subject: [PATCH 11/16] Regenerate the project map after promotion --- docs/PROJECT-MAP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/PROJECT-MAP.md b/docs/PROJECT-MAP.md index 507b501f..fc7e4b1e 100644 --- a/docs/PROJECT-MAP.md +++ b/docs/PROJECT-MAP.md @@ -90,6 +90,6 @@ Manifest-less directories under `plugins/` are core frontend surface and always ## Size -- test functions defined: **1503** (parametrised cases collect higher) +- test functions defined: **1507** (parametrised cases collect higher) - documented API paths: **333** (`docs/openapi.json`, regenerate with `scripts/gen_openapi.py`) From 1c88acad3c05c7a2739498c5fbce45c12881191d Mon Sep 17 00:00:00 2001 From: cproudlock Date: Sun, 30 Aug 2026 11:21:57 -0400 Subject: [PATCH 12/16] Correct the branch list: wilm exists and is active Recorded that wilm no longer existed, on the evidence of the local gitea clone where it was merged and its ref deleted. That was wrong. It lives on the corporate GitHub and the other site is developing the .NET endpoint agent on it. The dev box cannot see that branch - no account there - so its absence locally was never evidence of anything, and fetch-wilm-branch.cmd on the share exists precisely to bring it back for review. Says so now, so the next reader does not draw the same conclusion from the same missing ref. Scope: product --- CONTRIBUTING.md | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 654127ef..848e3026 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -153,9 +153,15 @@ Tests are required for: ## Branches: what belongs on main, and what does not -Two branches. `main` is the product every site runs and the publication mirror -exports. `` branches - currently `wj` - hold work that is not ready for -other sites to see. +`main` is the product every site runs and the publication mirror exports. +`` branches hold work that is not ready for other sites to see. There are +two: `wj` here, and `wilm` on the corporate GitHub, where the other site +develops the .NET endpoint agent. + +A site branch is not a fork. Both feed `main`, and the sooner they do it the +cheaper it is. A branch the dev box cannot see is still a branch: `wilm` is not +visible from here because this machine has no GitHub account, which is why +`fetch-wilm-branch.cmd` on the share exists to bring it back for review. **Most work is product.** ADR-015 gives site differences three homes on `main`: a setting with a neutral default, seed data, or a site-namespaced directory @@ -188,10 +194,9 @@ quietly becomes the product. **The failure to watch for is not what you think.** Promoting something site-specific by accident is caught by the ADR-015 gate on the way into `main`. -The real risk is product work sitting on a site branch forever because nobody -decided - which is how the `wilm` branch stopped being a merge and became a -port, costing a MySQL dialect fix, a contract violation and a plugin install to -bring back. +The real risk is product work sitting on a site branch until bringing it back is +a port rather than a merge - one such round trip cost a MySQL dialect fix, a +contract violation and a plugin install. So rebase the site branch on `main` often, and promote as you go rather than in one batch at the end. From faace01f2d9bb9555ff372ce0b40e97bf2ee3996 Mon Sep 17 00:00:00 2001 From: cproudlock Date: Sun, 30 Aug 2026 12:48:56 -0400 Subject: [PATCH 13/16] Do not offer to promote work that already landed Promotion cherry-picks, which gives the commit a new sha on main. wj-status compared by sha via main..wj, so after promoting ten commits it still listed all ten as unpromoted - and would have invited promoting the same work twice. git cherry compares patch content instead and marks an already-present commit with a leading '-'. When nothing is genuinely outstanding it now says so, counts what has already landed, and shows how far the branch is BEHIND main with the rebase to run. Behind was the state that mattered here and the tool could not express it at all. Scope: product --- tools/wj-status.sh | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/tools/wj-status.sh b/tools/wj-status.sh index d133cd53..f35fe110 100755 --- a/tools/wj-status.sh +++ b/tools/wj-status.sh @@ -44,10 +44,25 @@ if ! git rev-parse --verify --quiet refs/heads/wj >/dev/null; then exit 1 fi -COMMITS="$(git rev-list --reverse "$RANGE")" +# git cherry, not rev-list. Promotion CHERRY-PICKS, which gives the commit a new +# sha on main, so `main..wj` keeps listing work that already landed - and this +# tool would then invite you to promote it a second time. `git cherry` compares +# patch content and marks an already-present commit with a leading '-'. +COMMITS="$(git cherry main wj | awk '$1 == "+" { print $2 }')" + +ALREADY="$(git cherry main wj | awk '$1 == "-" { print $2 }' | wc -l)" if [ -z "$COMMITS" ]; then echo "Nothing on wj that main does not have." + if [ "$ALREADY" -gt 0 ]; then + echo "($ALREADY commit(s) here have already landed on main as cherry-picks.)" + fi + echo + echo "wj is behind main by:" + git log --oneline wj..main | sed 's/^/ /' | head -10 + echo + echo "Rebase it before starting new work:" + echo " git checkout wj && git rebase main" exit 0 fi From 58b53b50f69a0bfa596a9a4f16f43fa3035a280b Mon Sep 17 00:00:00 2001 From: cproudlock Date: Sun, 30 Aug 2026 12:53:14 -0400 Subject: [PATCH 14/16] Refuse --dist anywhere but main The bundle became one file per branch, but the frontend dists did not: they are still frontend-dist-subpath-{ops,shopdb}, fixed paths, and update-prod-server.cmd robocopies the shopdb one onto the production server with /MIR - a full mirror - with no idea which branch built it. So --dist from a site branch would have replaced production's frontend with unreleased work at the next deploy, silently, and nothing downstream could have told. Worse than the bundle collision it was sitting beside, because the dist is what actually lands on the server. Branch-qualifying the directories would only move the trap to whoever pointed a deploy at the wrong one. Production runs the product, so the export now refuses and says which commits to promote instead. Scope: product --- tools/export-github.sh | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tools/export-github.sh b/tools/export-github.sh index 7c4ca2a5..baf9c824 100755 --- a/tools/export-github.sh +++ b/tools/export-github.sh @@ -261,6 +261,31 @@ echo "bundle: $BUNDLE" # --- 6. optional per-instance dists (both bases: /ops = dev, /shopdb = prod) --- # Two instances run on the box; each needs its own base-path build. Always # rebuild BOTH so prod never ships a stale frontend. +# --dist IS MAIN ONLY. +# +# These paths are not branch-qualified and cannot safely be: update-prod-server +# .cmd robocopies frontend-dist-subpath-shopdb onto the production server with +# /MIR, a full mirror, and has no idea which branch built it. So --dist from a +# site branch would replace production's frontend with unreleased work at the +# next deploy, silently, and the deploy script could not tell. +# +# Branch-qualifying the directory would leave the same trap for whoever pointed +# a deploy at the wrong one. Production runs the product; a site branch has no +# business producing the artefact production installs. +if [ "$BUILD_DIST" = 1 ] && [ "$BRANCH" != "main" ]; then + echo "REFUSED: --dist on branch '$BRANCH'." >&2 + echo >&2 + echo "The dist directories are what update-prod-server.cmd mirrors onto the" >&2 + echo "production server, and they are not branch-qualified. Building them" >&2 + echo "from '$BRANCH' would put unreleased work on production at the next" >&2 + echo "deploy, with nothing to reveal it." >&2 + echo >&2 + echo "Promote the commits to main and export from there:" >&2 + echo " tools/wj-status.sh" >&2 + echo " tools/promote-to-main.sh ..." >&2 + exit 1 +fi + if [ "$BUILD_DIST" = 1 ]; then cd "$WORK/frontend" for base in ops shopdb; do From 4c8291628f4c6943a918b7c0cd6acb84e46012bb Mon Sep 17 00:00:00 2001 From: cproudlock Date: Sun, 30 Aug 2026 12:57:05 -0400 Subject: [PATCH 15/16] Call the inventory panel Assets, not Estate The panel had no group label before it was one panel, so grouping it needed a heading and it got 'Estate' - RMM idiom, and the word the leadership documents already used for the whole of what a site owns. Wrong register for this surface. The sidebar already groups exactly these things under a section named assets, so the dashboard had invented a second word for a group the app already names, and the panel's own total row says 'All assets' - the heading and the total disagreed inside one panel. 'Estate' is also enterprise-IT and largely British idiom; it earns its place in an executive summary and not on a screen read at 6am. The scoped classes follow the label, so the markup does not keep a word the UI stopped using. The documents keep 'estate', where it is doing real work for a leadership reader. Scope: product --- frontend/src/views/Dashboard.vue | 92 ++++++++++++++++---------------- 1 file changed, 46 insertions(+), 46 deletions(-) diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue index 703a0db4..ad886ee4 100644 --- a/frontend/src/views/Dashboard.vue +++ b/frontend/src/views/Dashboard.vue @@ -18,13 +18,13 @@ @@ -165,7 +165,7 @@ onMounted(async () => { From 511c3ffcee2455666feeb8c2ee21e8af98e07f2f Mon Sep 17 00:00:00 2001 From: cproudlock Date: Sun, 30 Aug 2026 15:18:13 -0400 Subject: [PATCH 16/16] Show every status the asset total contains, and what it excludes Asked twice what "All assets" covered, which is the signal a label is underspecified rather than a reader being slow. The endpoint already counted every status to build its rollup and then returned two of them, so a reader could see In Use and had no way to learn where the rest of the total went. It now returns them all, and the panel lists them under the total - indented and quieter, because they break the total down rather than adding to it. Three things this makes visible that were not guessable: Retired assets are IN the total. Retired is a status on an active record, not the same as deactivated, and seven of them sit inside this site's 610. There is an "Inventory" status. I would have hardcoded In Use, In Repair and Retired and silently dropped it. The rows are built from whatever the API returns, sorted by count, so a status a site adds appears rather than disappearing - which matters for sites adopting with their own seed data. Rows held back by isactive are in NO status bucket, so they cannot be found by adding the buckets up. That gap is exactly what makes a dashboard total disagree with a list page, so the count is reported and captioned. The caption states the counting policy - active records only, dual-bay pairs counted once - and the dual-path clause renders only when that site toggle is on, since asserting it unconditionally would be false at a site that has it off. Full suite 2182, frontend 314. --- frontend/src/views/Dashboard.vue | 54 ++++++++++++++++++++++++-------- shopdb/core/api/dashboard.py | 33 +++++++++++++++++++ 2 files changed, 74 insertions(+), 13 deletions(-) diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue index ad886ee4..1555f7c3 100644 --- a/frontend/src/views/Dashboard.vue +++ b/frontend/src/views/Dashboard.vue @@ -69,14 +69,24 @@ {{ stats.totalassets || 0 }} - -

- {{ stats.activeassets || 0 }} in use - - - {{ stats.inrepair || 0 }} in repair + +

+ {{ row.name }} + {{ row.count }} +
+ + +

+ Active records only, dual-bay + pairs counted once. + + {{ stats.inactiveexcluded }} deactivated record{{ stats.inactiveexcluded === 1 ? '' : 's' }} + excluded.

@@ -133,6 +143,17 @@ const summaryloaded = ref(false) // hold - and to what the guest role reaches, when nobody is signed in. const navitems = ref([]) +// Every status in the total, biggest first. Sorted rather than fixed-order +// because the statuses are seed data a site can change - hardcoding "In Use, +// In Repair, Retired" would silently omit a status somebody added. +const statusrows = computed(() => { + const statuses = stats.value.statuses || {} + return Object.keys(statuses) + .map((name) => ({ name, count: statuses[name] })) + .filter((row) => row.count > 0) + .sort((a, b) => b.count - a.count) +}) + const openable = computed( // The dashboard itself is not somewhere to go from the dashboard. () => navitems.value.filter((item) => item.route && item.route !== '/')) @@ -224,11 +245,18 @@ span.assets-k { color: var(--text); font-weight: 600; } border-bottom: none; } -.assets-status { - margin: 0.5rem 0 0; - font-size: 0.85rem; +/* Status rows sit UNDER the total, indented and quieter, because they break + the total down rather than adding to it. Same weight as the type rows would + read as more things to add up. */ +.assets-sub { border-bottom: none; padding: 0.15rem 0 0.15rem 0.75rem; } +.assets-subk { color: var(--text-light); font-size: 0.85rem; } +a.assets-subk:hover { text-decoration: none; } +.assets-subv { font-weight: 500; font-size: 0.85rem; color: var(--text-light); } + +.assets-caption { + margin: 0.6rem 0 0; + font-size: 0.78rem; + line-height: 1.45; color: var(--text-light); } -.assets-inuse { color: var(--success); font-weight: 600; font-variant-numeric: tabular-nums; } -.assets-zero { color: var(--text-light); } diff --git a/shopdb/core/api/dashboard.py b/shopdb/core/api/dashboard.py index d8350d15..5d197e22 100644 --- a/shopdb/core/api/dashboard.py +++ b/shopdb/core/api/dashboard.py @@ -38,6 +38,19 @@ def _count_by_type(assettype): return query.count() +def _inactive_excluded(): + """Counted-type assets held back by isactive, i.e. soft-deleted. + + Reported so the page can account for itself. A total that silently drops + rows is the reason somebody compares it with a list page and finds a + difference they cannot explain. + """ + return (db.session.query(Asset).join(AssetType) + .filter(Asset.isactive == False, + AssetType.assettype.in_(COUNTEDTYPES)) + .count()) + + COUNTEDTYPES = ('machine', 'computer', 'network_device', 'printer', 'measuring_tool') @@ -69,6 +82,10 @@ def _countedassets(): @require_permission('assets.view') def get_dashboard(): """Get dashboard summary data (asset-based).""" + # Imported here, as in the helpers above: the dualpath service reaches back + # into models, so a module-level import is circular. + from shopdb.core.services.dualpath import dualpath_single_machine_enabled + machine_count = _count_by_type('machine') pc_count = _count_by_type('computer') network_count = _count_by_type('network_device') @@ -99,6 +116,22 @@ def get_dashboard(): 'totalmeasuringtool': measuringtool_count, 'activeassets': status_dict.get('In Use', 0), 'inrepair': status_dict.get('In Repair', 0), + # What the totals actually cover, so the page can say so rather than + # leaving a reader to work it out from a number that surprised them. + # Every count here filters isactive, and the dual-path collapse is a + # per-site toggle - asserting it unconditionally would be false at a + # site that has it off. + 'countsactiveonly': True, + 'dualpathcollapsed': bool(dualpath_single_machine_enabled()), + # Every status, not just the two that had callers. The counts were + # already built for the rollup above and then thrown away, so a reader + # could see "In Use" and had no way to learn where the rest went. + 'statuses': status_dict, + # A DIFFERENT thing to a status, and the one that makes a total + # disagree with a list page. isactive is the soft-delete flag: these + # rows are excluded from every count above, so they are in no status + # bucket and cannot be found by adding the buckets up. + 'inactiveexcluded': _inactive_excluded(), # Structured data 'counts': { 'machines': machine_count,