feat(scripts): sync-statements.sh --in-place — раскладка копии в рабочее дерево хоста с защитой от .gitignore и путей для контура (#120)

Также: evaluate при коммите VERSION без arm_statements/ даёт код 20 вместо падения с 128 (T2.1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-18 19:13:16 +04:00
parent 7851446999
commit b5ad8a0f4c
2 changed files with 164 additions and 10 deletions

View File

@ -11,7 +11,8 @@
# --check только проверить рабочее дерево хоста (незакоммиченная правка тоже расхождение): # --check только проверить рабочее дерево хоста (незакоммиченная правка тоже расхождение):
# 0 — копия синхронна, 10 — предупреждение (копия отстала или перечень кодов в docs отличается), # 0 — копия синхронна, 10 — предупреждение (копия отстала или перечень кодов в docs отличается),
# 20 — расхождение (прямая правка копии, чужая версия, нет копии, нет зависимости, код вне перечня) # 20 — расхождение (прямая правка копии, чужая версия, нет копии, нет зависимости, код вне перечня)
# --in-place разложить копию в --host-dir (режим пока не реализован) # --in-place разложить копию в рабочее дерево хоста (без git-операций); 1 — защиты (.gitignore хоста, пути для контура),
# 20 — отказ (правка копии, чужая версия, нет зависимости, код вне перечня)
# --dry-run worktree хоста без issue и PR (режим пока не реализован); вместе с --in-place — код 2 # --dry-run worktree хоста без issue и PR (режим пока не реализован); вместе с --in-place — код 2
# --host arm-core | tabular-parser | scan-gateway; по умолчанию — по origin хоста (arm-core, arm-tabular-parser, # --host arm-core | tabular-parser | scan-gateway; по умолчанию — по origin хоста (arm-core, arm-tabular-parser,
# arm-scan-gateway), иначе по имени каталога # arm-scan-gateway), иначе по имени каталога
@ -58,7 +59,7 @@ while [ $# -gt 0 ]; do
--docs) [ $# -ge 2 ] || die "--docs требует значение" 2; DOCS=$2; shift ;; --docs) [ $# -ge 2 ] || die "--docs требует значение" 2; DOCS=$2; shift ;;
--core-dir) [ $# -ge 2 ] || die "--core-dir требует значение" 2; CORE_DIR=$2; shift ;; --core-dir) [ $# -ge 2 ] || die "--core-dir требует значение" 2; CORE_DIR=$2; shift ;;
--wt-root) [ $# -ge 2 ] || die "--wt-root требует значение" 2; WT_ROOT=$2; shift ;; --wt-root) [ $# -ge 2 ] || die "--wt-root требует значение" 2; WT_ROOT=$2; shift ;;
-h|--help) sed -n "2,23p" "$0"; exit 0 ;; -h|--help) sed -n "2,24p" "$0"; exit 0 ;;
*) die "неизвестный аргумент $1" 2 ;; *) die "неизвестный аргумент $1" 2 ;;
esac esac
shift shift
@ -137,11 +138,13 @@ helper() {
# Печатает находки, выставляет EVAL_CODE (0/10/20) и EVAL_WARNS (причины предупреждения через «; »). # Печатает находки, выставляет EVAL_CODE (0/10/20) и EVAL_WARNS (причины предупреждения через «; »).
EVAL_CODE=0 EVAL_CODE=0
EVAL_WARNS="" EVAL_WARNS=""
COPY_BAD=0 # 1 — copy негодна по условиям 1–4 (не из-за зависимостей и словаря)
evaluate() { evaluate() {
local copy=$1 reqs=$2 lines=$3 host=$4 local copy=$1 reqs=$2 lines=$3 host=$4
local version="" v7="" archive="$TMPD/archive" known=1 line local version="" v7="" archive="$TMPD/archive" known=1 line
EVAL_CODE=0; EVAL_WARNS="" EVAL_CODE=0; EVAL_WARNS=""; COPY_BAD=0
bad() { echo "$1"; EVAL_CODE=20; } bad() { echo "$1"; EVAL_CODE=20; }
badcopy() { bad "$1"; COPY_BAD=1; } # условия 1–4: сама копия негодна (правка, чужая версия)
warn() { echo "$1"; [ "$EVAL_CODE" = 20 ] || EVAL_CODE=10; EVAL_WARNS="${EVAL_WARNS:+$EVAL_WARNS; }$2"; } warn() { echo "$1"; [ "$EVAL_CODE" = 20 ] || EVAL_CODE=10; EVAL_WARNS="${EVAL_WARNS:+$EVAL_WARNS; }$2"; }
if [ ! -d "$copy" ]; then if [ ! -d "$copy" ]; then
@ -153,22 +156,28 @@ evaluate() {
if ! printf '%s' "$version" | grep -Eq '^[0-9a-f]{40}$' \ if ! printf '%s' "$version" | grep -Eq '^[0-9a-f]{40}$' \
|| ! git -C "$SRC" cat-file -e "$version^{commit}" 2>/dev/null; then || ! git -C "$SRC" cat-file -e "$version^{commit}" 2>/dev/null; then
known=0 known=0
bad "VERSION ${version:-(нет файла)} нет в истории arm-statements$([ "$FETCH_OK" = 1 ] || echo '; fetch не удался — сделайте fetch в arm-statements и повторите')" badcopy "VERSION ${version:-(нет файла)} нет в истории arm-statements$([ "$FETCH_OK" = 1 ] || echo '; fetch не удался — сделайте fetch в arm-statements и повторите')"
fi fi
if [ "$known" = 1 ]; then if [ "$known" = 1 ]; then
# 3. версия должна быть из истории ref # 3. версия должна быть из истории ref
if ! git -C "$SRC" merge-base --is-ancestor "$version" "$REF_SHA" 2>/dev/null; then if ! git -C "$SRC" merge-base --is-ancestor "$version" "$REF_SHA" 2>/dev/null; then
bad "чужая версия: $v7 не из истории $REF" badcopy "чужая версия: $v7 не из истории $REF"
fi fi
# 4. файлы копии против git archive <VERSION> # 4. файлы копии против git archive <VERSION>
rm -rf "$archive"; mkdir -p "$archive" rm -rf "$archive"; mkdir -p "$archive"
git -C "$SRC" archive "$version" arm_statements | tar -x -C "$archive" local diffs="" have_archive=1
local diffs if ! git -C "$SRC" archive "$version" arm_statements 2>/dev/null | tar -x -C "$archive" 2>/dev/null \
diffs=$(diff -rq -x VERSION -x __pycache__ -x '*.pyc' -x .pytest_cache -x .mypy_cache -x .ruff_cache -x .DS_Store \ || [ ! -d "$archive/arm_statements" ]; then
"$archive/arm_statements" "$copy" 2>&1 || true) have_archive=0
badcopy "в коммите VERSION ($v7) нет arm_statements/ — чужая версия"
fi
if [ "$have_archive" = 1 ]; then
diffs=$(diff -rq -x VERSION -x __pycache__ -x '*.pyc' -x .pytest_cache -x .mypy_cache -x .ruff_cache -x .DS_Store \
"$archive/arm_statements" "$copy" 2>&1 || true)
fi
if [ -n "$diffs" ]; then if [ -n "$diffs" ]; then
bad "прямая правка копии (файлы относительно arm_statements/):" badcopy "прямая правка копии (файлы относительно arm_statements/):"
while IFS= read -r line; do while IFS= read -r line; do
[ -n "$line" ] || continue [ -n "$line" ] || continue
case "$line" in case "$line" in
@ -214,6 +223,15 @@ evaluate() {
} }
VERSION7="" VERSION7=""
# summary: итоговая строка как у --check
summary() {
case "$EVAL_CODE" in
0) echo "statements-check $HOST_NAME: 0 — копия синхронна с arm-statements@$REF ($VERSION7)" ;;
10) echo "statements-check $HOST_NAME: 10 — ПРЕДУПРЕЖДЕНИЕ: $EVAL_WARNS" ;;
*) echo "statements-check $HOST_NAME: 20 — расхождение, копию не принимать" ;;
esac
}
# --- режимы -------------------------------------------------------------------------------------------------------- # --- режимы --------------------------------------------------------------------------------------------------------
if [ "$MODE" = check ]; then if [ "$MODE" = check ]; then
LINES="" LINES=""
@ -229,4 +247,79 @@ if [ "$MODE" = check ]; then
exit "$EVAL_CODE" exit "$EVAL_CODE"
fi fi
# lay_copy <host_root> <commit>: заменить <host_root>/api/arm_statements копией из источника, записать VERSION;
# затем защиты (игнор .gitignore хоста, содержимое, на котором остановится перенос в контур) — любая даёт код 1
lay_copy() {
local root=$1 commit=$2 dest="$1/api/arm_statements" ign pth bad=0
rm -rf "$dest"; mkdir -p "$root/api"
git -C "$SRC" archive "$commit" arm_statements | tar -x -C "$root/api"
printf '%s\n' "$commit" > "$dest/VERSION"
ign=$(git -C "$root" ls-files --others --ignored --exclude-standard -- api/arm_statements 2>/dev/null \
| grep -Ev '(^|/)(__pycache__|\.pytest_cache|\.mypy_cache|\.ruff_cache)(/|$)|\.pyc$' || true)
if [ -n "$ign" ]; then
echo "файлы копии игнорируются .gitignore хоста, в коммит не попадут:"
printf '%s\n' "$ign" | sed 's/^/ /'
bad=1
fi
pth=$(grep -rIn "/Users/\|/home/[a-z]\|git.aepif.ru\|uv venv\|uv pip" "$dest" || true)
if [ -n "$pth" ]; then
echo "копия сломает снимок для контура:"
printf '%s\n' "$pth" | sed 's/^/ /'
bad=1
fi
[ "$bad" = 0 ] || return 1
return 0
}
if [ "$MODE" = in-place ]; then
COPY="$HOST_DIR/api/arm_statements"
REQS="$HOST_DIR/api/requirements.txt"
LINES=""
if [ "$HOST_NAME" = arm-core ] && [ -f "$HOST_DIR/api/rules/statement_lines.yaml" ]; then
LINES="$HOST_DIR/api/rules/statement_lines.yaml"
fi
# 1. существующая копия не должна быть правленой или чужой
if [ -d "$COPY" ]; then
evaluate "$COPY" "$REQS" "" "$HOST_NAME" > "$TMPD/ev.out"
if [ "$COPY_BAD" = 1 ]; then
cat "$TMPD/ev.out"
echo "statements-check $HOST_NAME: 20 — отказ: копия правлена или чужая, ничего не записано"
exit 20
fi
fi
# 2. зависимости и словарь имён по коммиту --ref
refuse=""
git -C "$SRC" show "$REF_SHA:pyproject.toml" > "$TMPD/ref-pyproject.toml" 2>/dev/null || : > "$TMPD/ref-pyproject.toml"
helper deps "$TMPD/ref-pyproject.toml" "$REQS"
if [ "$HELPER_RC" = 20 ]; then refuse="$HELPER_OUT"; fi
if [ -n "$LINES" ]; then
git -C "$SRC" show "$REF_SHA:arm_statements/rules/statement_code_orders.yaml" > "$TMPD/ref-orders.yaml" 2>/dev/null \
|| : > "$TMPD/ref-orders.yaml"
helper lines "$LINES" "$TMPD/ref-orders.yaml"
if [ "$HELPER_RC" = 20 ]; then refuse="${refuse:+$refuse
}$HELPER_OUT"; fi
fi
if [ -n "$refuse" ]; then
echo "$refuse"
echo "statements-check $HOST_NAME: 20 — отказ: $REF несовместим с хостом, ничего не записано"
exit 20
fi
# 3. уже на ref?
LAID=0
if [ -d "$COPY" ] && [ -f "$COPY/VERSION" ] && [ "$(tr -d ' \t\r\n' < "$COPY/VERSION")" = "$REF_SHA" ]; then
echo "раскладывать нечего: копия уже на $REF ($REF7)"
else
# 4. раскладка
lay_copy "$HOST_DIR" "$REF_SHA" || die "раскладка выполнена, но копию нельзя коммитить (см. выше)" 1
LAID=1
fi
# 5. итог
evaluate "$COPY" "$REQS" "$LINES" "$HOST_NAME"
summary
if [ "$LAID" = 1 ]; then
echo "закоммитьте api/arm_statements отдельным коммитом: git -C $HOST_DIR add api/arm_statements"
fi
exit "$EVAL_CODE"
fi
die "режим не реализован" 1 die "режим не реализован" 1

View File

@ -230,5 +230,66 @@ echo "# правка" >> "$AC/arm_statements/steps.py"
sync 20 "check: правка + отставание = 20" --check --host-dir "$WORK/arm-core" sync 20 "check: правка + отставание = 20" --check --host-dir "$WORK/arm-core"
reset_hosts reset_hosts
# коммит без arm_statements/ (T2.1): код 20 с итоговой строкой, а не 128
NOARCH=$(git -C "$SRC" commit-tree "$(git -C "$SRC" hash-object -t tree /dev/null)" -m "без пакета")
printf '%s\n' "$NOARCH" > "$AC/arm_statements/VERSION"
sync 20 "check: в коммите VERSION нет arm_statements/" --check --host-dir "$WORK/arm-core"
check "$(has 'нет arm_statements/' && has 'statements-check arm-core: 20' && echo 0 || echo 1)" "check: нет arm_statements/ — сообщение и итог"
reset_hosts
# --- sync-statements.sh --in-place --------------------------------------------------------------------------------
S2=$(git -C "$SRC" rev-parse origin/main)
FR="$WORK/fresh/tabular-parser"
mkdir -p "$WORK/fresh"
git init -q --bare "$WORK/fresh/arm-tabular-parser.git"
git clone -q "$WORK/fresh/arm-tabular-parser.git" "$FR" 2>/dev/null
git -C "$FR" checkout -q -b main
put "$FR/api/requirements.txt" $'PyYAML==6.0.3'
commit_push "$FR" "fresh" main
sync 0 "in-place: первичная раскладка" --in-place --host-dir "$FR" --ref origin/main
check "$([ "$(tr -d '\n' < "$FR/api/arm_statements/VERSION")" = "$S2" ] && echo 0 || echo 1)" "in-place: VERSION = полный хэш S2"
rm -rf "$WORK/arc"; mkdir -p "$WORK/arc"; git -C "$SRC" archive "$S2" arm_statements | tar -x -C "$WORK/arc"
check "$(diff -r -x VERSION "$WORK/arc/arm_statements" "$FR/api/arm_statements" >/dev/null && echo 0 || echo 1)" "in-place: копия равна git archive S2"
check "$(git -C "$FR" status --porcelain | grep -qv '^?? api/arm_statements' && echo 1 || echo 0)" "in-place: хост менялся только в api/arm_statements"
check "$(has закоммитьте && echo 0 || echo 1)" "in-place: подсказка про коммит"
sync 0 "in-place: повтор" --in-place --host-dir "$FR" --ref origin/main
check "$(has 'раскладывать нечего' && echo 0 || echo 1)" "in-place: повтор — раскладывать нечего"
sync 0 "in-place: обновление arm-core S1 -> S2" --in-place --host-dir "$WORK/arm-core"
check "$([ "$(tr -d '\n' < "$AC/arm_statements/VERSION")" = "$S2" ] && echo 0 || echo 1)" "in-place: arm-core VERSION = S2"
echo "# правка" >> "$AC/arm_statements/steps.py"
sync 20 "in-place: отказ при правке" --in-place --host-dir "$WORK/arm-core" --ref origin/main
check "$(grep -q '^# правка' "$AC/arm_statements/steps.py" && [ "$(tr -d '\n' < "$AC/arm_statements/VERSION")" = "$S2" ] && echo 0 || echo 1)" "in-place: правка на месте, VERSION прежний"
reset_hosts
TPR="$WORK/tabular-parser/api"
put "$TPR/requirements.txt" "aiohttp==3.14.3"
sync 20 "in-place: отказ без зависимости (обновление)" --in-place --host-dir "$WORK/tabular-parser"
check "$([ "$(tr -d '\n' < "$TPR/arm_statements/VERSION")" = "$S1" ] && has PyYAML && echo 0 || echo 1)" "in-place: копия не тронута, в выводе PyYAML"
reset_hosts
cp "$FR/api/requirements.txt" "$WORK/req.bak"
put "$FR/api/requirements.txt" "aiohttp==3.14.3"
sync 20 "in-place: отказ без зависимости (fresh)" --in-place --host-dir "$FR" --ref origin/main
cp "$WORK/req.bak" "$FR/api/requirements.txt"
cp "$H/lines_x.yaml" "$AC/rules/statement_lines.yaml"
sync 20 "in-place: код словаря вне перечня" --in-place --host-dir "$WORK/arm-core" --ref origin/main
check "$([ "$(tr -d '\n' < "$AC/arm_statements/VERSION")" = "$S1" ] && has 1120 && echo 0 || echo 1)" "in-place: словарь — копия не тронута, 1120 в выводе"
reset_hosts
# защиты: коммиты S3/S4 на ветке test-guards, чтобы origin/main (S2) остался прежним
git -C "$SRC" checkout -q -b test-guards
put "$SRC/arm_statements/tests/golden/case.json" '{}'
commit_push "$SRC" "S3 json" test-guards
put "$FR/.gitignore" '*.json'
sync 1 "in-place: .gitignore прячет файл" --in-place --host-dir "$FR" --ref origin/test-guards
check "$(has case.json && has .gitignore && echo 0 || echo 1)" "in-place: в выводе case.json и .gitignore"
rm "$FR/.gitignore"
echo "# /Users/someone" >> "$SRC/arm_statements/steps.py"
commit_push "$SRC" "S4 путь" test-guards
sync 1 "in-place: содержимое для контура" --in-place --host-dir "$FR" --ref origin/test-guards
check "$(has '/Users/' && echo 0 || echo 1)" "in-place: в выводе /Users/"
git -C "$SRC" checkout -q main
[ "$fail" = 0 ] || exit 1 [ "$fail" = 0 ] || exit 1
echo "ok: sync-statements.sh — все сценарии пройдены" echo "ok: sync-statements.sh — все сценарии пройдены"