diff --git a/.github/workflows/process.yml b/.github/workflows/process.yml index 818423bc..21a1dcf9 100644 --- a/.github/workflows/process.yml +++ b/.github/workflows/process.yml @@ -266,9 +266,18 @@ jobs: before=$(git rev-parse "origin/$BRANCH") echo "dev впереди на $behind коммит(ов) — привожу ветку" if ! git rebase origin/dev; then + # Список снимается ДО abort: он же снимает состояние конфликта, и + # тогда автору достаётся «не ребейзится» без единого имени файла (#364). + files=$(git diff --name-only --diff-filter=U | sort -u | paste -sd'\n' -) git rebase --abort || true - echo "conflict=true" >> "$GITHUB_OUTPUT" + { + echo 'conflict=true' + echo 'conflicts<> "$GITHUB_OUTPUT" echo "::warning::ветка $BRANCH не ребейзится на dev без конфликта — ревью не запускается" + printf 'конфликтуют:\n%s\n' "${files:-(git не назвал файлы)}" exit 0 fi # --force-with-lease с явным ожидаемым значением обязателен: между @@ -308,10 +317,17 @@ jobs: GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} NUM: ${{ github.event.issue.number }} BRANCH: ${{ steps.branch.outputs.name }} + CONFLICTS: ${{ steps.rebase.outputs.conflicts }} run: | cat > /tmp/stale.md <> "$GITHUB_OUTPUT" echo "to=$to" >> "$GITHUB_OUTPUT" + # Ревью идёт десятки минут, а dev за это время двигается (28 августа — + # четыре раза за день). Вердикт при этом вынесен по дереву, которое уже не + # совпадает с вершиной линии, и слияние приведёт ветку к dev — то есть в + # dev уедет код, отличный от прочитанного (§7.2). Молчать об этом нельзя, + # но и шуметь на каждом прогоне ни к чему: строка появляется только когда + # dev действительно ушёл и вердикт зелёный, то есть слияние вот-вот + # случится (#364). + - name: dev ушёл вперёд, пока шло ревью + if: steps.rebase.outputs.conflict != 'true' && needs.guard.outputs.stage == 'code' + env: + GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} + NUM: ${{ github.event.issue.number }} + MATERIAL: ${{ steps.material.outputs.sha }} + GREEN: ${{ steps.decide.outputs.green }} + run: | + git fetch -q origin dev + moved=$(git rev-list --count "$MATERIAL..origin/dev") + echo "dev продвинулся на $moved коммит(ов) с момента фиксации материала" + echo "- dev продвинулся на **$moved** коммит(ов) во время ревью" >> "$GITHUB_STEP_SUMMARY" + if [ "$moved" -eq 0 ] || [ "$GREEN" != "true" ]; then exit 0; fi + short=$(git rev-parse --short "$MATERIAL") + gh issue comment "$NUM" --repo "${{ github.repository }}" --body \ + "Пока шло ревью, \`dev\` продвинулся на $moved коммит(ов). Материал ревью — \`$short\`. Вердикт вынесен по дереву, которое уже не совпадает с вершиной линии: слияние приведёт ветку к dev, и это другой код (§7.2)." + # S8-merged утверждает, что код в dev. Значит слияние обязано произойти # ДО метки, иначе она врёт в промежутке. # diff --git a/docs/TESTING.md b/docs/TESTING.md index d1ce4d88..9af7d3e3 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -791,9 +791,16 @@ node scripts/pre-push-gate.mjs --no-smokes --no-mutants node scripts/pre-push-gate.mjs --max-smokes=3 --max-mutants=1 ``` -Что прогоняется: `npx tsc --noEmit`, `npm test`, смоки, выбранные -`scripts/smoke-select.mjs` по диффу, и мутанты, выбранные -`scripts/mutation-gate.mjs --changed` по тем же файлам. Замер на реальном +Что прогоняется: проверка, что ветка приведена к `origin/dev`, `npx tsc +--noEmit`, `npm test`, смоки, выбранные `scripts/smoke-select.mjs` по диффу, и +мутанты, выбранные `scripts/mutation-gate.mjs --changed` по тем же файлам. + +Отставание от `dev` — предупреждение, а не провал набора: гейтом остаётся +конвейер, который приводит ветку сам (#257) и забыть не может. Смысл локальной +проверки в другом: после любого ребейза разбор на ревью становится полным, а не +по дельте (§7.2), а конфликт всё равно чинится на машине автора — дешевле +узнать об этом до пуша, чем из комментария через сорок минут (#364). Отключается +флагом `--no-rebase-check`. Замер на реальном диапазоне (`953f675~1..953f675`, правка `src/houseplan-card.ts`): типы 5 с, юниты 17–19 с, два смока 22 с — **46 секунд** на всё. diff --git a/scripts/branch-state.mjs b/scripts/branch-state.mjs new file mode 100644 index 00000000..395c8f3f --- /dev/null +++ b/scripts/branch-state.mjs @@ -0,0 +1,50 @@ +/** + * Состояние ветки задачи относительно `dev` (#364). + * + * Конвейер приводит ветку к `dev` сам (#257) и при конфликте возвращает задачу, + * не тратя цикл ревью. Но конфликт при этом обнаруживается в комментарии через + * сорок минут, а чинится всё равно на машине автора. Дешевле сказать ему это до + * пуша — тогда конвейеру нечего ребейзить, а конфликт виден там, где есть руки. + * + * Отставание — предупреждение, а не запрет: гейтом остаётся конвейер, который + * забыть не может. Локальная проверка лишь убирает лишний круг. + */ + +/** + * Совет по ребейзу. `null` означает «ветка содержит весь dev, делать нечего». + * + * @param {{ behind: number, base?: string }} state + */ +export function rebaseAdvice({ behind, base = 'origin/dev' }) { + const count = Number(behind); + if (!Number.isFinite(count) || count <= 0) return null; + const commits = count === 1 ? 'коммит' : count < 5 ? 'коммита' : 'коммитов'; + return `ветка отстала от ${base} на ${count} ${commits}.` + + ` Конвейер приведёт её сам перед ревью, но после ребейза разбор станет` + + ` полным, а не по дельте (§7.2). Дешевле сделать это здесь:` + + ` git fetch origin && git rebase ${base}`; +} + +/** + * Пути, на которых встал ребейз, из вывода `git diff --name-only --diff-filter=U`. + * + * Читается ДО `git rebase --abort`: abort снимает состояние конфликта вместе с + * этим списком, и тогда автору остаётся только «не ребейзится», без единого + * имени файла. + */ +export function conflictingPaths(output) { + return [...new Set(String(output ?? '').split('\n') + .map((line) => line.trim()) + .filter(Boolean))].sort(); +} + +/** Строка о том, что `dev` ушёл вперёд, пока шло ревью. `null`, если не ушёл. */ +export function devMovedNote({ moved, sha }) { + const count = Number(moved); + if (!Number.isFinite(count) || count <= 0) return null; + const commits = count === 1 ? 'коммит' : count < 5 ? 'коммита' : 'коммитов'; + const at = sha ? ` Материал ревью — \`${sha}\`.` : ''; + return `Пока шло ревью, \`dev\` продвинулся на ${count} ${commits}.${at}` + + ' Вердикт вынесен по дереву, которое уже не совпадает с вершиной линии:' + + ' слияние приведёт ветку к dev, и это другой код (§7.2).'; +} diff --git a/scripts/pre-push-gate.mjs b/scripts/pre-push-gate.mjs index bdbaf6ab..45db8a71 100644 --- a/scripts/pre-push-gate.mjs +++ b/scripts/pre-push-gate.mjs @@ -31,6 +31,7 @@ import { spawnSync } from 'node:child_process'; import { existsSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { rebaseAdvice } from './branch-state.mjs'; const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const argv = process.argv.slice(2); @@ -63,6 +64,26 @@ const capture = (command, args) => spawnSync(command, args, { cwd: ROOT, encodin const steps = []; const skipped = []; +// ---- приведена ли ветка к dev --------------------------------------------- +// Конвейер ребейзит сам (#257), но после ребейза разбор становится полным, а не +// по дельте (§7.2), и конфликт всплывает в комментарии через сорок минут вместо +// машины автора. Поэтому предупреждение, а не гейт: гейтом остаётся конвейер. +if (!flag('no-rebase-check')) { + const fetched = capture('git', ['fetch', '-q', 'origin', base.replace(/^origin\//, '')]); + if (fetched.status !== 0) { + skipped.push(`проверка отставания от ${base} — git fetch не удался`); + } else { + const ancestor = capture('git', ['merge-base', '--is-ancestor', base, head]); + if (ancestor.status === 0) { + console.log(`\n── Ветка\n содержит весь ${base}, ребейз не нужен`); + } else { + const counted = capture('git', ['rev-list', '--count', `${head}..${base}`]); + const advice = rebaseAdvice({ behind: Number(counted.stdout.trim()), base }); + if (advice) skipped.push(advice); + } + } +} + // ---- что тронуто ---------------------------------------------------------- const diff = capture('git', ['diff', '--name-only', `${base}..${head}`]); if (diff.status !== 0) { diff --git a/test/branch-state.test.mjs b/test/branch-state.test.mjs new file mode 100644 index 00000000..8fab678d --- /dev/null +++ b/test/branch-state.test.mjs @@ -0,0 +1,50 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { conflictingPaths, devMovedNote, rebaseAdvice } from '../scripts/branch-state.mjs'; + +// #364. Конвейер приводит ветку к dev сам (#257) и при конфликте возвращает +// задачу, не тратя цикл ревью. Но конфликт всплывает в комментарии через сорок +// минут, а чинится на машине автора; и после любого ребейза разбор становится +// полным, а не по дельте (§7.2). Эти helpers переносят обнаружение туда, где +// есть руки, и делают возврат адресным. + +test('приведённая ветка совета не требует (#364)', () => { + assert.equal(rebaseAdvice({ behind: 0 }), null); + assert.equal(rebaseAdvice({ behind: -1 }), null); + assert.equal(rebaseAdvice({ behind: NaN }), null); +}); + +test('отставшая ветка получает число и готовую команду (#364)', () => { + const one = rebaseAdvice({ behind: 1 }); + assert.match(one, /отстала от origin\/dev на 1 коммит\./); + assert.match(one, /git rebase origin\/dev/); + // Причина названа: без неё совет читается как придирка, а он про цену. + assert.match(one, /разбор станет\s+полным, а не по дельте/); + assert.match(rebaseAdvice({ behind: 3 }), /на 3 коммита/); + assert.match(rebaseAdvice({ behind: 12 }), /на 12 коммитов/); +}); + +test('база может быть не только origin/dev (#364)', () => { + const advice = rebaseAdvice({ behind: 2, base: 'origin/main' }); + assert.match(advice, /от origin\/main на 2 коммита/); + assert.match(advice, /git rebase origin\/main/); +}); + +test('конфликтующие пути читаются, чистятся и сортируются (#364)', () => { + assert.deepEqual( + conflictingPaths('src/b.ts\nsrc/a.ts\n\n src/b.ts \n'), + ['src/a.ts', 'src/b.ts'], + ); + assert.deepEqual(conflictingPaths(''), []); + assert.deepEqual(conflictingPaths(null), []); +}); + +test('уход dev во время ревью описывается только когда он был (#364)', () => { + assert.equal(devMovedNote({ moved: 0 }), null); + const note = devMovedNote({ moved: 2, sha: 'abc1234' }); + assert.match(note, /продвинулся на 2 коммита/); + assert.match(note, /`abc1234`/); + // Вывод, ради которого строка и нужна: вердикт вынесен по другому дереву. + assert.match(note, /§7\.2/); +});