592 lines
24 KiB
Bash
592 lines
24 KiB
Bash
|
|
#!/usr/bin/env bash
|
||
|
|
#
|
||
|
|
# Create the documentation tree a project is expected to have, copied from the
|
||
|
|
# template's own copy of it, without ever overwriting a document that is
|
||
|
|
# already there.
|
||
|
|
#
|
||
|
|
# bash scripts/scaffold.sh # into the current directory
|
||
|
|
# bash scripts/scaffold.sh --dry-run # list everything, write nothing
|
||
|
|
# bash scripts/scaffold.sh --into ../new-app
|
||
|
|
# bash scripts/scaffold.sh --force # overwrite; needs a clean git tree
|
||
|
|
# SCAFFOLD_TEMPLATE_ROOT=/path/to/Template bash scripts/scaffold.sh
|
||
|
|
#
|
||
|
|
# ===========================================================================
|
||
|
|
# TEMPLATE COPY — configure this before the first run
|
||
|
|
# ===========================================================================
|
||
|
|
#
|
||
|
|
# Copy to `scripts/scaffold.sh` and set TEMPLATE_ROOT in the CONFIGURATION
|
||
|
|
# block below, or pass it as SCAFFOLD_TEMPLATE_ROOT. It is empty and it stays
|
||
|
|
# empty: the template sits at a different absolute path on every machine, and
|
||
|
|
# a plausible-looking default would quietly resolve to whatever checkout
|
||
|
|
# happens to be there. That is not a "file not found" — it is thirteen
|
||
|
|
# documents arriving with another project's Owner lines, another project's
|
||
|
|
# review dates and another project's branding, each one reading as this
|
||
|
|
# project's own the moment it lands.
|
||
|
|
#
|
||
|
|
# Assumes: bash and coreutils. git is used only by --force, and only to read.
|
||
|
|
#
|
||
|
|
# ## Why this exists
|
||
|
|
#
|
||
|
|
# The convention this tree encodes is cheap to state and, on the evidence,
|
||
|
|
# almost never adopted whole. Eight repositories were surveyed before the
|
||
|
|
# template was last rewritten: exactly one kept the ledger the template asked
|
||
|
|
# for, three had invented their own dialects, and one had pushed nothing but a
|
||
|
|
# README. Nobody refused the convention. They started projects, and a
|
||
|
|
# convention that has to be re-typed from memory at the start of each one is a
|
||
|
|
# convention that decays into four different ones.
|
||
|
|
#
|
||
|
|
# So the first act of a new project is a command, not a reading exercise. What
|
||
|
|
# is left afterwards — the placeholders, the tracker, the branding — is work
|
||
|
|
# that genuinely needs a person, and it is visible precisely because the
|
||
|
|
# skeleton around it is already correct.
|
||
|
|
#
|
||
|
|
# ## Never overwriting is the whole safety property
|
||
|
|
#
|
||
|
|
# Every other rule here follows from one: a scaffold that clobbers a written
|
||
|
|
# document destroys work that nothing would notice was gone. There is no test
|
||
|
|
# for "the architecture notes used to say more than this", no build failure, no
|
||
|
|
# diff to read if the file was never committed. It is silent at the moment it
|
||
|
|
# happens and silent forever afterwards, and the natural time to re-run a
|
||
|
|
# scaffold — partway into a project, to pick up a file that was missed — is
|
||
|
|
# exactly the time the tree is full of real writing.
|
||
|
|
#
|
||
|
|
# So an existing file is reported as "kept" and left untouched, always. --force
|
||
|
|
# exists for the one honest case (a tree scaffolded from a stale template, not
|
||
|
|
# yet written into) and it refuses twice over: once for the tree, which must be
|
||
|
|
# clean, and again for each file, which git must actually track. A clean tree
|
||
|
|
# alone is not enough — it says nothing about ignored files — so the worst
|
||
|
|
# --force can do is something `git checkout` undoes.
|
||
|
|
#
|
||
|
|
# ## What it deliberately does not do
|
||
|
|
#
|
||
|
|
# It does not touch git. No init, no add, no commit — creating files is enough,
|
||
|
|
# and committing them is a judgement about what belongs in the history of this
|
||
|
|
# project, made by whoever will answer for that history.
|
||
|
|
#
|
||
|
|
# It does not fill anything in. The angle-bracket placeholders are left
|
||
|
|
# standing because a document whose header says it was reviewed on a date
|
||
|
|
# nobody reviewed it is worse than one that visibly has not been filled in: the
|
||
|
|
# first is trusted at the exact moment it should not be. An unfilled header
|
||
|
|
# keeps asking; an invented one stops.
|
||
|
|
#
|
||
|
|
# It does not copy branding. See the docs/data/img note near the bottom.
|
||
|
|
#
|
||
|
|
# It does not copy the scripts that live in this folder. They are in the
|
||
|
|
# template's docs tree for the template's own reasons, and each one needs
|
||
|
|
# configuring before its first run — an unconfigured release.sh landing in
|
||
|
|
# every new repository is a loaded gun, not a head start. Copy those one at a
|
||
|
|
# time, having read them.
|
||
|
|
#
|
||
|
|
# It does not create the tracker labels, the first milestone or the project's
|
||
|
|
# README, and it does not check conformance. It reports what it did. Whether
|
||
|
|
# the project passes is a different question, asked by a different thing.
|
||
|
|
|
||
|
|
set -uo pipefail
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# CONFIGURATION — set this one, then delete this banner.
|
||
|
|
#
|
||
|
|
# Empty on purpose. See the note at the top: a default here does not fail, it
|
||
|
|
# copies the wrong project's documents and says "created" thirteen times.
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
# Absolute path to the template checkout — the directory holding `docs/`.
|
||
|
|
TEMPLATE_ROOT="${SCAFFOLD_TEMPLATE_ROOT:-}"
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# The tree, listed explicitly.
|
||
|
|
#
|
||
|
|
# A recursive copy would be shorter and would drag in whatever else is sitting
|
||
|
|
# in the template's docs tree today, including this script. An explicit list is
|
||
|
|
# also the only thing that can tell the difference between "the template does
|
||
|
|
# not have that file" and "the copy quietly produced twelve of thirteen" — the
|
||
|
|
# second looks identical to success from the outside.
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
DOCS=(
|
||
|
|
docs/DOC_TRUST_MAP.md
|
||
|
|
docs/WORK_CYCLE.md
|
||
|
|
docs/TOOLS.md
|
||
|
|
docs/planning/PROJECT_PLAN.md
|
||
|
|
docs/qa/ClaudeQAPlan.md
|
||
|
|
docs/qa/ClaudeQACoverage.md
|
||
|
|
docs/qa/ClaudeReport.md
|
||
|
|
docs/history/HISTORY.md
|
||
|
|
docs/history/DEVELOPMENT_LOG.md
|
||
|
|
docs/history/BATCH_LEDGER.md
|
||
|
|
docs/security/SECURITY.md
|
||
|
|
docs/security/SECURITY_CHECKLIST.md
|
||
|
|
docs/architecture/README.md
|
||
|
|
docs/design/README.md
|
||
|
|
docs/data/README.md
|
||
|
|
)
|
||
|
|
|
||
|
|
# Directories made empty, holding nothing this script is willing to invent.
|
||
|
|
DIRS=(docs/data/img)
|
||
|
|
|
||
|
|
# The three names the conformance check looks for, by name, in docs/data/img.
|
||
|
|
BRANDING=(icon.webp logo.webp banner.webp)
|
||
|
|
|
||
|
|
# The placeholders the template leaves for a person to replace. Used only to
|
||
|
|
# count what is still outstanding at the end — if the template's placeholder
|
||
|
|
# style changes this finds nothing, which is why finding nothing prints nothing
|
||
|
|
# rather than an all-clear.
|
||
|
|
PLACEHOLDER_RE='<(Project|YYYY-MM-DD|who maintains this)>'
|
||
|
|
|
||
|
|
say() { printf '\033[1mscaffold:\033[0m %s\n' "$*" >&2; }
|
||
|
|
die() { printf '\033[1mscaffold:\033[0m %s\n' "$*" >&2; exit 1; }
|
||
|
|
|
||
|
|
usage() {
|
||
|
|
cat >&2 <<'EOF'
|
||
|
|
scaffold: create the documentation tree a project is expected to have.
|
||
|
|
|
||
|
|
bash scripts/scaffold.sh into the current directory
|
||
|
|
bash scripts/scaffold.sh --into <path> into another project root
|
||
|
|
bash scripts/scaffold.sh --dry-run list what would happen, write nothing
|
||
|
|
bash scripts/scaffold.sh --force overwrite; refuses on a dirty git tree
|
||
|
|
|
||
|
|
SCAFFOLD_TEMPLATE_ROOT path to the template checkout (required, no default)
|
||
|
|
SCAFFOLD_INTO same as --into
|
||
|
|
|
||
|
|
An existing file is never overwritten without --force. Exits non-zero if any
|
||
|
|
path was skipped, including one the template itself is missing.
|
||
|
|
EOF
|
||
|
|
}
|
||
|
|
|
||
|
|
DRY_RUN=""
|
||
|
|
FORCE=""
|
||
|
|
TARGET="${SCAFFOLD_INTO:-}"
|
||
|
|
|
||
|
|
# Set only when --force resolves a repository. Declared here so the copy loop
|
||
|
|
# can test it under `set -u` whether or not --force was passed.
|
||
|
|
force_top=""
|
||
|
|
|
||
|
|
# Whether the operator named the target, as opposed to inheriting the current
|
||
|
|
# directory. The guard below is only for the inherited case.
|
||
|
|
TARGET_NAMED=""
|
||
|
|
[ -n "$TARGET" ] && TARGET_NAMED="yes"
|
||
|
|
|
||
|
|
while [ "$#" -gt 0 ]; do
|
||
|
|
case "$1" in
|
||
|
|
--dry-run) DRY_RUN="yes" ;;
|
||
|
|
--force) FORCE="yes" ;;
|
||
|
|
--into)
|
||
|
|
# A bare --into would otherwise swallow the next flag as a path and
|
||
|
|
# scaffold into a directory called "--dry-run".
|
||
|
|
[ "$#" -ge 2 ] || die "--into needs a path."
|
||
|
|
case "$2" in -*) die "--into needs a path, got '$2'." ;; esac
|
||
|
|
# An empty string is not "here". Without this it becomes "." while still
|
||
|
|
# counting as named, which silently disables the project-root guard below
|
||
|
|
# — the one case where the guard is most likely to be right.
|
||
|
|
[ -n "$2" ] || die "--into was given an empty path. Use --into . to mean the
|
||
|
|
current directory."
|
||
|
|
TARGET="$2"; TARGET_NAMED="yes"; shift ;;
|
||
|
|
--into=*)
|
||
|
|
TARGET="${1#--into=}"; TARGET_NAMED="yes"
|
||
|
|
[ -n "$TARGET" ] || die "--into needs a path." ;;
|
||
|
|
-h|--help) usage; exit 0 ;;
|
||
|
|
*) die "unknown argument '$1'. Run --help for usage." ;;
|
||
|
|
esac
|
||
|
|
shift
|
||
|
|
done
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Resolve both ends before touching either. Everything checkable is checked
|
||
|
|
# before the first file is written.
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
[ -n "$TEMPLATE_ROOT" ] || die "set TEMPLATE_ROOT (or SCAFFOLD_TEMPLATE_ROOT) — the path to the
|
||
|
|
template checkout holding docs/. See the CONFIGURATION block."
|
||
|
|
|
||
|
|
# A leading ~ inside a variable is a literal character, not $HOME. The failure
|
||
|
|
# is otherwise "no such directory" naming a path the operator can see exists,
|
||
|
|
# which sends them looking in the wrong place.
|
||
|
|
case "$TEMPLATE_ROOT" in
|
||
|
|
'~'*) die "TEMPLATE_ROOT is '$TEMPLATE_ROOT' — a leading ~ is not expanded inside a
|
||
|
|
variable. Write the path out, or use \"\$HOME/...\"." ;;
|
||
|
|
esac
|
||
|
|
|
||
|
|
[ -d "$TEMPLATE_ROOT" ] || die "TEMPLATE_ROOT '$TEMPLATE_ROOT' is not a directory."
|
||
|
|
|
||
|
|
TEMPLATE_ABS=$(cd "$TEMPLATE_ROOT" 2>/dev/null && pwd -P) \
|
||
|
|
|| die "cannot enter TEMPLATE_ROOT '$TEMPLATE_ROOT' — check permissions."
|
||
|
|
|
||
|
|
[ -d "$TEMPLATE_ABS/docs" ] || die "'$TEMPLATE_ABS' has no docs/ directory, so it is not the template
|
||
|
|
root. TEMPLATE_ROOT is the checkout, not the docs folder inside it."
|
||
|
|
|
||
|
|
# Same trap as TEMPLATE_ROOT above, and worse here: the bare "does not exist"
|
||
|
|
# message names a path the operator can see does exist.
|
||
|
|
case "$TARGET" in
|
||
|
|
'~'*) die "target is '$TARGET' — a leading ~ is not expanded inside a variable or
|
||
|
|
inside quotes. Write the path out, or use \"\$HOME/...\"." ;;
|
||
|
|
esac
|
||
|
|
|
||
|
|
[ -n "$TARGET" ] || TARGET="."
|
||
|
|
|
||
|
|
# Refused rather than created. `mkdir -p` on a typo succeeds, and the result is
|
||
|
|
# a complete, correct documentation tree in a directory that should not exist —
|
||
|
|
# which reads as success and is found weeks later, if at all.
|
||
|
|
[ -d "$TARGET" ] || die "target '$TARGET' does not exist. Create it first — scaffolding into a
|
||
|
|
mistyped path produces a tree that looks entirely correct."
|
||
|
|
|
||
|
|
TARGET_ABS=$(cd "$TARGET" 2>/dev/null && pwd -P) \
|
||
|
|
|| die "cannot enter target '$TARGET' — check permissions."
|
||
|
|
|
||
|
|
[ "$TARGET_ABS" != "$TEMPLATE_ABS" ] \
|
||
|
|
|| die "the target is the template root itself. There is nothing to scaffold."
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# An inherited current directory is the one input nobody typed.
|
||
|
|
#
|
||
|
|
# Run from src/ or from docs/ itself — both of which are where you are when you
|
||
|
|
# think to run this — the default would build a second, wrong-place tree that
|
||
|
|
# every convention here then fails to find. Only guessed targets are checked;
|
||
|
|
# --into is an instruction and is obeyed.
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
if [ -z "$TARGET_NAMED" ]; then
|
||
|
|
if command -v git >/dev/null 2>&1; then
|
||
|
|
guess_top=$(git -C "$TARGET_ABS" rev-parse --show-toplevel 2>/dev/null)
|
||
|
|
|
||
|
|
if [ -n "$guess_top" ]; then
|
||
|
|
guess_top=$(cd "$guess_top" 2>/dev/null && pwd -P)
|
||
|
|
|
||
|
|
if [ -n "$guess_top" ] && [ "$guess_top" != "$TARGET_ABS" ]; then
|
||
|
|
die "you are in a subdirectory of '$guess_top'. docs/ belongs at the project
|
||
|
|
root. Re-run with --into '$guess_top', or --into . to mean here."
|
||
|
|
fi
|
||
|
|
fi
|
||
|
|
else
|
||
|
|
# Said out loud rather than passed over. Without git this check cannot run
|
||
|
|
# at all, and a silent skip reads exactly like a check that passed.
|
||
|
|
say "note: git is not installed, so the \"is this the project root?\" check did"
|
||
|
|
say " not run. '$TARGET_ABS' is being taken as the root on trust."
|
||
|
|
fi
|
||
|
|
fi
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# --force may only run where the damage is undoable.
|
||
|
|
#
|
||
|
|
# Overwriting is recoverable exactly when git already holds the current
|
||
|
|
# contents. A dirty tree means it does not, and an untracked file is the worst
|
||
|
|
# case of all — scaffolded an hour ago, written into since, never committed,
|
||
|
|
# and nothing anywhere remembers what it said. So untracked counts as dirty.
|
||
|
|
#
|
||
|
|
# This is the tree-wide half of the check, and on its own it is not enough:
|
||
|
|
# `git status --porcelain` is silent about ignored files, so the per-file
|
||
|
|
# `ls-files` test in the copy loop below is what actually closes the gap.
|
||
|
|
#
|
||
|
|
# Every way of failing to answer the question is refused, not passed over.
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
if [ -n "$FORCE" ]; then
|
||
|
|
command -v git >/dev/null 2>&1 \
|
||
|
|
|| die "--force needs git, so an overwrite can be undone with 'git checkout'."
|
||
|
|
|
||
|
|
force_top=$(git -C "$TARGET_ABS" rev-parse --show-toplevel 2>/dev/null) \
|
||
|
|
|| force_top=""
|
||
|
|
|
||
|
|
[ -n "$force_top" ] || die "--force refused: '$TARGET_ABS' is not inside a git repository, so an
|
||
|
|
overwrite would be permanent. Commit the tree somewhere first, or drop
|
||
|
|
--force and let existing files be kept."
|
||
|
|
|
||
|
|
if ! dirty=$(git -C "$force_top" status --porcelain 2>/dev/null); then
|
||
|
|
die "--force refused: could not read git status in '$force_top'. Not knowing
|
||
|
|
whether the tree is clean is a reason to stop, not to continue."
|
||
|
|
fi
|
||
|
|
|
||
|
|
if [ -n "$dirty" ]; then
|
||
|
|
say "--force refused: '$force_top' has uncommitted or untracked changes:"
|
||
|
|
printf '%s\n' "$dirty" | head -n 10 >&2
|
||
|
|
die "commit or stash them first. --force is only safe when git already holds
|
||
|
|
what it is about to replace."
|
||
|
|
fi
|
||
|
|
fi
|
||
|
|
|
||
|
|
say "template $TEMPLATE_ABS"
|
||
|
|
say "target $TARGET_ABS"
|
||
|
|
[ -n "$DRY_RUN" ] && say "--dry-run: nothing will be written."
|
||
|
|
[ -n "$FORCE" ] && say "--force: existing files WILL be replaced (git tree is clean)."
|
||
|
|
say ""
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# The copy.
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
CREATED=0
|
||
|
|
KEPT=0
|
||
|
|
OVERWRITTEN=0
|
||
|
|
MISSING_TEMPLATE=0
|
||
|
|
UNREADABLE=0
|
||
|
|
CONFLICTED=0
|
||
|
|
FAILED=0
|
||
|
|
HEADERLESS=0
|
||
|
|
|
||
|
|
MISSING_LIST=()
|
||
|
|
PLACEHOLDER_LIST=()
|
||
|
|
|
||
|
|
report() { printf ' %-13s %s%s\n' "$1" "$2" "${3:+ ($3)}" >&2; }
|
||
|
|
|
||
|
|
# Every document opens with an H1 and then a fenced status header. Checked on
|
||
|
|
# the template's copy rather than the result, because a header missing here
|
||
|
|
# means the template drifted and every project scaffolded from it starts
|
||
|
|
# non-conformant — a fact about the template, reported as one.
|
||
|
|
has_status_header() {
|
||
|
|
awk 'NR==1 { if ($0 !~ /^# /) { bad=1; exit } }
|
||
|
|
NR>1 && NR<=4 { if ($0 ~ /^```/) { ok=1; exit } }
|
||
|
|
NR>4 { exit }
|
||
|
|
END { exit (ok && !bad) ? 0 : 1 }' "$1" 2>/dev/null
|
||
|
|
}
|
||
|
|
|
||
|
|
for rel in "${DIRS[@]}"; do
|
||
|
|
dst="$TARGET_ABS/$rel"
|
||
|
|
|
||
|
|
if [ -d "$dst" ]; then
|
||
|
|
report "kept" "$rel/" "directory exists"
|
||
|
|
KEPT=$((KEPT + 1))
|
||
|
|
continue
|
||
|
|
fi
|
||
|
|
|
||
|
|
if [ -e "$dst" ] || [ -L "$dst" ]; then
|
||
|
|
report "SKIPPED" "$rel/" "exists and is not a directory"
|
||
|
|
CONFLICTED=$((CONFLICTED + 1))
|
||
|
|
continue
|
||
|
|
fi
|
||
|
|
|
||
|
|
if [ -n "$DRY_RUN" ]; then
|
||
|
|
report "would create" "$rel/" "empty directory"
|
||
|
|
CREATED=$((CREATED + 1))
|
||
|
|
continue
|
||
|
|
fi
|
||
|
|
|
||
|
|
if mkdir -p "$dst" 2>/dev/null; then
|
||
|
|
report "created" "$rel/" "empty directory"
|
||
|
|
CREATED=$((CREATED + 1))
|
||
|
|
else
|
||
|
|
report "FAILED" "$rel/" "could not create"
|
||
|
|
FAILED=$((FAILED + 1))
|
||
|
|
fi
|
||
|
|
done
|
||
|
|
|
||
|
|
for rel in "${DOCS[@]}"; do
|
||
|
|
src="$TEMPLATE_ABS/$rel"
|
||
|
|
dst="$TARGET_ABS/$rel"
|
||
|
|
|
||
|
|
# Reported, counted and returned in the exit status. A template missing a
|
||
|
|
# file it is supposed to carry produces a tree that is wrong in a way only
|
||
|
|
# this run can see — by the next run, "not in the template" and "already
|
||
|
|
# here" look the same from the target's side.
|
||
|
|
if [ ! -e "$src" ]; then
|
||
|
|
report "SKIPPED" "$rel" "missing from the template"
|
||
|
|
MISSING_TEMPLATE=$((MISSING_TEMPLATE + 1))
|
||
|
|
MISSING_LIST+=("$rel")
|
||
|
|
continue
|
||
|
|
fi
|
||
|
|
|
||
|
|
# Present but unreadable is a different fact with a different fix — a mode
|
||
|
|
# bit here, a missing document there. Reporting both as "missing from the
|
||
|
|
# template" sends someone to write a file that is already sitting in front
|
||
|
|
# of them.
|
||
|
|
if [ ! -f "$src" ] || [ ! -r "$src" ]; then
|
||
|
|
report "SKIPPED" "$rel" "in the template but not a readable regular file"
|
||
|
|
UNREADABLE=$((UNREADABLE + 1))
|
||
|
|
continue
|
||
|
|
fi
|
||
|
|
|
||
|
|
has_status_header "$src" || HEADERLESS=$((HEADERLESS + 1))
|
||
|
|
|
||
|
|
# A symlink is followed by cp, so "writing into docs/" could land anywhere on
|
||
|
|
# the disk, including on top of the file it points at. Never written through,
|
||
|
|
# in either direction, --force included.
|
||
|
|
if [ -L "$dst" ]; then
|
||
|
|
report "SKIPPED" "$rel" "is a symlink — never written through"
|
||
|
|
CONFLICTED=$((CONFLICTED + 1))
|
||
|
|
continue
|
||
|
|
fi
|
||
|
|
|
||
|
|
if [ -e "$dst" ] && [ ! -f "$dst" ]; then
|
||
|
|
report "SKIPPED" "$rel" "exists and is not a regular file"
|
||
|
|
CONFLICTED=$((CONFLICTED + 1))
|
||
|
|
continue
|
||
|
|
fi
|
||
|
|
|
||
|
|
if [ -f "$dst" ]; then
|
||
|
|
if [ -z "$FORCE" ]; then
|
||
|
|
report "kept" "$rel" "already here, not touched"
|
||
|
|
KEPT=$((KEPT + 1))
|
||
|
|
continue
|
||
|
|
fi
|
||
|
|
|
||
|
|
# An identical file is not an overwrite worth announcing, and calling it
|
||
|
|
# one inflates the number that is supposed to mean "work was replaced".
|
||
|
|
if cmp -s "$src" "$dst"; then
|
||
|
|
report "kept" "$rel" "identical to the template"
|
||
|
|
KEPT=$((KEPT + 1))
|
||
|
|
continue
|
||
|
|
fi
|
||
|
|
|
||
|
|
# The clean-tree check above is necessary and not sufficient. `git status
|
||
|
|
# --porcelain` says nothing about ignored files, so a repository with
|
||
|
|
# docs/qa/ClaudeReport.md in .gitignore reports clean while git holds no
|
||
|
|
# copy of it at all. Overwriting that is exactly the permanent, silent loss
|
||
|
|
# the whole script is built to refuse, announced as "(git tree is clean)".
|
||
|
|
#
|
||
|
|
# So the question is asked per file, of git, in the only form that answers
|
||
|
|
# it: does git track this path. Anything else — ignored, untracked, or an
|
||
|
|
# unreadable index — is refused and counted, never overwritten.
|
||
|
|
if ! git -C "$force_top" ls-files --error-unmatch -- "$dst" >/dev/null 2>&1; then
|
||
|
|
report "SKIPPED" "$rel" "--force: git does not track it (ignored?) — overwrite would be permanent"
|
||
|
|
CONFLICTED=$((CONFLICTED + 1))
|
||
|
|
continue
|
||
|
|
fi
|
||
|
|
|
||
|
|
if [ -n "$DRY_RUN" ]; then
|
||
|
|
report "would REPLACE" "$rel" "--force, differs from the template"
|
||
|
|
OVERWRITTEN=$((OVERWRITTEN + 1))
|
||
|
|
continue
|
||
|
|
fi
|
||
|
|
elif [ -n "$DRY_RUN" ]; then
|
||
|
|
report "would create" "$rel"
|
||
|
|
CREATED=$((CREATED + 1))
|
||
|
|
continue
|
||
|
|
fi
|
||
|
|
|
||
|
|
if ! mkdir -p "$(dirname "$dst")" 2>/dev/null; then
|
||
|
|
report "FAILED" "$rel" "could not create its directory"
|
||
|
|
FAILED=$((FAILED + 1))
|
||
|
|
continue
|
||
|
|
fi
|
||
|
|
|
||
|
|
existed=""
|
||
|
|
[ -f "$dst" ] && existed="yes"
|
||
|
|
|
||
|
|
if ! cp -- "$src" "$dst" 2>/dev/null; then
|
||
|
|
report "FAILED" "$rel" "copy failed"
|
||
|
|
FAILED=$((FAILED + 1))
|
||
|
|
continue
|
||
|
|
fi
|
||
|
|
|
||
|
|
# Verified rather than assumed. A short write on a full disk leaves a file
|
||
|
|
# that exists, reports as created, and is a truncated document — which is the
|
||
|
|
# one outcome here that is worse than not copying at all.
|
||
|
|
if ! cmp -s "$src" "$dst"; then
|
||
|
|
report "FAILED" "$rel" "copied file does not match the template — check disk space"
|
||
|
|
FAILED=$((FAILED + 1))
|
||
|
|
continue
|
||
|
|
fi
|
||
|
|
|
||
|
|
if [ -n "$existed" ]; then
|
||
|
|
report "REPLACED" "$rel" "--force"
|
||
|
|
OVERWRITTEN=$((OVERWRITTEN + 1))
|
||
|
|
else
|
||
|
|
report "created" "$rel"
|
||
|
|
CREATED=$((CREATED + 1))
|
||
|
|
fi
|
||
|
|
|
||
|
|
grep -Eq "$PLACEHOLDER_RE" "$dst" 2>/dev/null && PLACEHOLDER_LIST+=("$rel")
|
||
|
|
done
|
||
|
|
|
||
|
|
SKIPPED=$((MISSING_TEMPLATE + UNREADABLE + CONFLICTED + FAILED))
|
||
|
|
|
||
|
|
say ""
|
||
|
|
|
||
|
|
if [ -n "$DRY_RUN" ]; then
|
||
|
|
say "--dry-run: nothing was written. It would have made"
|
||
|
|
say " ${CREATED} created, ${KEPT} kept, ${OVERWRITTEN} replaced, ${SKIPPED} skipped."
|
||
|
|
else
|
||
|
|
say "${CREATED} created, ${KEPT} kept, ${OVERWRITTEN} replaced, ${SKIPPED} skipped."
|
||
|
|
fi
|
||
|
|
|
||
|
|
say " skipped breaks down as ${MISSING_TEMPLATE} missing from the template,"
|
||
|
|
say " ${UNREADABLE} present in the template but unreadable, ${CONFLICTED} conflicting"
|
||
|
|
say " with something already at that path, ${FAILED} failed."
|
||
|
|
|
||
|
|
if [ "$MISSING_TEMPLATE" -gt 0 ]; then
|
||
|
|
say ""
|
||
|
|
say "WARNING: the template at $TEMPLATE_ABS does not have:"
|
||
|
|
printf ' %s\n' ${MISSING_LIST[@]+"${MISSING_LIST[@]}"} >&2
|
||
|
|
say " This tree is incomplete and nothing downstream will say so."
|
||
|
|
say " Fix the template, or write those files by hand."
|
||
|
|
fi
|
||
|
|
|
||
|
|
if [ "$HEADERLESS" -gt 0 ]; then
|
||
|
|
say ""
|
||
|
|
say "WARNING: ${HEADERLESS} template document(s) do not open with an H1 followed by"
|
||
|
|
say " a fenced block within the first four lines — the shape a status"
|
||
|
|
say " header is written in. Only that shape was checked: the field"
|
||
|
|
say " names (Status / Owner / Last reviewed / Governs / Review"
|
||
|
|
say " trigger) were not read, so a passing document is not a"
|
||
|
|
say " conformant one. Every project scaffolded from this template"
|
||
|
|
say " inherits whatever is there. Fix it in the template."
|
||
|
|
fi
|
||
|
|
|
||
|
|
# Counted only among files this run created, and silent when there are none —
|
||
|
|
# an "all placeholders filled in" line would be a claim about documents this
|
||
|
|
# run never looked at, and about a placeholder style that may simply have
|
||
|
|
# changed underneath the pattern above.
|
||
|
|
if [ "${#PLACEHOLDER_LIST[@]}" -gt 0 ]; then
|
||
|
|
say ""
|
||
|
|
say "${#PLACEHOLDER_LIST[@]} file(s) written by this run still carry placeholders. Every status"
|
||
|
|
say "header needs a real Owner, a real Last reviewed date and a real Review"
|
||
|
|
say "trigger — that last line is what stops a document going quietly stale."
|
||
|
|
say "Find them with:"
|
||
|
|
say " grep -rnE '$PLACEHOLDER_RE' docs/"
|
||
|
|
fi
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Branding: named, measured, and never invented.
|
||
|
|
#
|
||
|
|
# The template's own docs/data/img is not a source. When this was written it
|
||
|
|
# held one zero-byte logo.webp, and copying that in would satisfy a check that
|
||
|
|
# looks for the name while producing a project card with nothing on it. The
|
||
|
|
# deeper reason is the one docs/data/README.md gives: a placeholder that looks
|
||
|
|
# deliberate outlives the issue that would have replaced it, because nobody
|
||
|
|
# files a ticket against an image that appears finished.
|
||
|
|
#
|
||
|
|
# So the directory is created empty and the three names are reported by what is
|
||
|
|
# actually there — including the difference between absent and present-but-
|
||
|
|
# empty, which the check itself distinguishes and a file listing does not.
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
img_dir="$TARGET_ABS/docs/data/img"
|
||
|
|
|
||
|
|
say ""
|
||
|
|
say "docs/data/img/ needs this project's own branding. The conformance check"
|
||
|
|
say "looks for exactly these three names, and reads webp by magic bytes rather"
|
||
|
|
say "than by extension — a renamed PNG does not pass:"
|
||
|
|
|
||
|
|
if [ -d "$img_dir" ]; then
|
||
|
|
for name in "${BRANDING[@]}"; do
|
||
|
|
if [ -s "$img_dir/$name" ]; then
|
||
|
|
report "present" "docs/data/img/$name"
|
||
|
|
elif [ -e "$img_dir/$name" ]; then
|
||
|
|
report "EMPTY" "docs/data/img/$name" "0 bytes — fails the magic-byte check"
|
||
|
|
else
|
||
|
|
report "absent" "docs/data/img/$name"
|
||
|
|
fi
|
||
|
|
done
|
||
|
|
else
|
||
|
|
# Not "none present". The directory does not exist yet, so nothing about
|
||
|
|
# these three files has been measured and no count would be honest.
|
||
|
|
say " not checked — $img_dir does not exist yet."
|
||
|
|
printf ' %s\n' ${BRANDING[@]+"${BRANDING[@]}"} >&2
|
||
|
|
fi
|
||
|
|
|
||
|
|
say ""
|
||
|
|
say "Nothing here can draw them. When one is absent, file an issue — title it"
|
||
|
|
say "for the asset, label it P2, and end the body with its Verify: line — rather"
|
||
|
|
say "than committing a placeholder."
|
||
|
|
|
||
|
|
say ""
|
||
|
|
say "note: no git command was run. Nothing is staged and nothing is committed;"
|
||
|
|
say " what belongs in this project's history is your call."
|
||
|
|
|
||
|
|
# Non-zero when the tree that came out is not the tree that was asked for. The
|
||
|
|
# caller — often another script, or an agent — needs that distinction to survive
|
||
|
|
# past the last line of output.
|
||
|
|
if [ "$SKIPPED" -gt 0 ]; then
|
||
|
|
say ""
|
||
|
|
die "${SKIPPED} path(s) were skipped, so this project is not fully scaffolded.
|
||
|
|
Resolve each one listed above and run this again — re-running is safe,
|
||
|
|
because everything already here is kept."
|
||
|
|
fi
|
||
|
|
|
||
|
|
exit 0
|