diff --git a/.github/workflows/process.yml b/.github/workflows/process.yml index 04226fae..efd9d825 100644 --- a/.github/workflows/process.yml +++ b/.github/workflows/process.yml @@ -413,11 +413,27 @@ jobs: behind=$(git rev-list --count "HEAD..origin/dev") 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 + # #643: doc-коммит ветки конфликтует с документами других задач в dev + # только в генерируемом docs/reviews/INDEX.md — всегда, на каждом + # сдвиге dev. Помощник ребейза пересобирает индекс по каталогу, если + # ВСЕ конфликты остановки — индекс, и отказывает (с abort) на любом + # другом. Список конфликтов он снимает ДО abort (#364) и печатает в + # stdout по строке. Помощник берётся из dev, а не из ветки: ветка, + # отставшая от dev, его ещё не несёт. + tools="$RUNNER_TEMP/rebase-tools" + rm -rf "$tools" && mkdir -p "$tools" + git archive origin/dev scripts | tar -x -C "$tools" + code=0 + files=$(node "$tools/scripts/rebase-generated.mjs" --onto=origin/dev) || code=$? + # Код 3 — отказ с перечнем (ребейз отменён); любой другой ненулевой — + # сбой самого помощника, конфликтом ветки он не выдаётся. + if [ "$code" -ne 0 ] && [ "$code" -ne 3 ]; then + git rebase --abort 2>/dev/null || true + echo "::error::помощник ребейза упал (код $code) — это сбой конвейера, а не конфликт ветки" + exit 1 + fi + if [ "$code" -eq 3 ]; then + files=$(printf '%s\n' "$files" | sed '/^$/d' | sort -u | paste -sd'\n' -) { echo 'conflict=true' echo 'conflicts< { must(git('checkout', '-q', '-B', 'merge-into-dev', branchTip), 'checkout'); - const r = spawnSync('git', ['-c', 'user.name=claude[bot]', '-c', 'user.email=209825114+claude[bot]@users.noreply.github.com', 'rebase', onto], { encoding: 'utf8' }); - if (r.status !== 0) { spawnSync('git', ['rebase', '--abort']); return null; } + // #643: doc-коммит ветки конфликтует с документами других задач в dev + // только в генерируемом INDEX.md — это решается пересборкой индекса, а + // не возвратом зелёной задачи в S6. Любой другой конфликт — отказ, как + // был; ребейз при отказе уже отменён помощником. + const r = rebaseRegenerating({ onto, gitPrefix: CONVEYOR_IDENTITY }); + if (!r.ok) { + console.log(`ребейз на ${onto} отменён: ${r.conflicts.join(', ') || r.reason}`); + return null; + } // #635 r2: dev мог принести новые документы ревью — снимок INDEX.md // в кандидате их не знает. Коммит индекса — doc-коммит конвейера: // patch-id его не видит (`:!docs/reviews`), а тест «индекс свеж» в diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs index dcc35a49..92504bb0 100644 --- a/scripts/mutation-registry.mjs +++ b/scripts/mutation-registry.mjs @@ -8782,6 +8782,75 @@ const MUTANT_DEFINITIONS = [ replace: " current = [line]; // mutant: last physical line instead of the paragraph\n }\n for (const paragraph of paragraphs) {", }], }, + // #643: ребейз разрешает конфликт в генерируемом docs/reviews/INDEX.md + // пересборкой — и только когда ВСЕ конфликты остановки — индекс. + { + id: 'rebase-index-resolves-mixed-conflicts', + guard: 'node --test --test-name-pattern="#643 (planStop|AC2)" test/rebase-generated.test.mjs', + because: '#643 AC2: a stop where the index conflicts together with any other path is a real ' + + 'conflict; resolving it because the index is among the paths commits conflict markers or ' + + 'loops the rebase instead of refusing with the list of files', + patches: [{ + file: 'scripts/rebase-generated.mjs', + find: " if (manual.length) return { action: 'abort', reason: 'manual', manual, conflicts };", + replace: " if (manual.length && !conflicts.includes(REVIEWS_INDEX_PATH)) return { action: 'abort', reason: 'manual', manual, conflicts }; // mutant: index anywhere unlocks the stop", + }], + }, + { + id: 'rebase-index-takes-a-side', + guard: 'node --test --test-name-pattern="#643 AC1" test/rebase-generated.test.mjs', + because: '#643 AC1: neither side of the rebase knows the other side\'s review documents; taking ' + + '--ours (dev) drops the branch\'s own document from the index, the result must be a rebuild', + patches: [{ + file: 'scripts/rebase-generated.mjs', + find: ' rebuildIndex();\n', + replace: " git(['checkout', '--ours', '--', REVIEWS_INDEX_PATH]); // mutant: take dev's index\n", + }], + }, + { + id: 'rebase-index-failure-leaves-half-rebase', + guard: 'node --test --test-name-pattern="#643: сбой пересборки" test/rebase-generated.test.mjs', + because: 'a crash in the middle of the rebase (the generator failed) must abort it; otherwise ' + + 'the caller\'s tree is left mid-rebase and the next step works on a detached half-result', + patches: [{ + file: 'scripts/rebase-generated.mjs', + find: " if (rebaseInProgress(git, cwd)) git(['rebase', '--abort'], { allowFailure: true });\n", + replace: ' // mutant: no abort on failure\n', + }], + }, + { + id: 'rebase-index-refusal-exits-like-a-crash', + guard: 'node --test --test-name-pattern="#643 CLI" test/rebase-generated.test.mjs', + because: 'Node exits with 1 on its own uncaught errors; a refusal reported as 1 is ' + + 'indistinguishable from a crashed helper, and the pipeline would call a broken tool a branch conflict', + patches: [{ + file: 'scripts/rebase-generated.mjs', + find: ' process.exit(EXIT_CONFLICT);', + replace: ' process.exit(1); // mutant: refusal = generic failure code', + }], + }, + { + id: 'merge-rebase-refuses-index-conflict', + guard: 'node --test --test-name-pattern="#643 AC1" test/merge-candidate.test.mjs', + because: '#643: a green task whose doc commit conflicts with dev only in the generated INDEX.md ' + + 'must merge through Validate; a plain rebase sends it back to S6 on every move of dev', + patches: [{ + file: 'scripts/merge-candidate.mjs', + find: ' const r = rebaseRegenerating({ onto, gitPrefix: CONVEYOR_IDENTITY });', + replace: " const r = spawnSync('git', [...CONVEYOR_IDENTITY, 'rebase', onto]).status === 0 ? { ok: true } : (spawnSync('git', ['rebase', '--abort']), { ok: false, conflicts: [] }); // mutant: plain rebase", + }], + }, + { + id: 'rebase-on-dev-stages-index-unrebuilt', + guard: 'node --test --test-name-pattern="#643 AC3" test/rebase-on-dev.test.mjs', + because: '#643 AC3: the author\'s rebase must rebuild INDEX.md like the pipeline does; staging ' + + 'the conflicted file as-is commits conflict markers into a generated file', + patches: [{ + file: 'scripts/rebase-on-dev.mjs', + find: ' onto: upstream, cwd, git,\n', + replace: ' onto: upstream, cwd, git, rebuildIndex: () => {}, // mutant: index staged as-is\n', + }], + }, // #634: ролевые конспекты PROCESS.md, цена входа агента, генерируемый // Snapshot и индекс приложений TESTING.md. Каждая защита — от тихого // расхождения выжимки с каноном или тихого роста входа. diff --git a/scripts/rebase-generated.mjs b/scripts/rebase-generated.mjs new file mode 100755 index 00000000..0aa16622 --- /dev/null +++ b/scripts/rebase-generated.mjs @@ -0,0 +1,172 @@ +#!/usr/bin/env node +// #643: ребейз, который сам решает конфликт в генерируемом индексе ревью. +// +// Шаг публикации документа ревью коммитит в ветку задачи и документ, и +// пересобранный `docs/reviews/INDEX.md`. Пока задача ждёт слияния, в `dev` +// приезжают документы других задач со своей версией индекса, и doc-коммит +// ветки конфликтует на ребейзе ВСЕГДА — 24.09 так отскочили в S6 зелёные +// #617, #618, #629, #642 и дважды до ревью #631. Решать там нечего: индекс — +// функция каталога `docs/reviews/` (класс C), и правильная версия — не «наша» +// и не «их», а пересборка по дереву, в котором остановился ребейз. +// +// Правило одно: остановку разрешает только набор конфликтов, в котором ВСЕ +// пути — индекс (или пути, которые вызывающий объявил своими: бандл в +// `rebase-on-dev.mjs`). Хоть один другой путь — `git rebase --abort` и +// перечень ВСЕХ конфликтующих файлов, индекс в нём тоже: автор видит полную +// картину, дерево и HEAD как были. +// +// node scripts/rebase-generated.mjs --onto=origin/dev +// +// Код 0 — ребейз завершён; 3 — отказ (ребейз отменён), в stdout по строке на +// конфликтующий путь (для `$GITHUB_OUTPUT` конвейера), пояснения — в stderr; +// любой другой — сбой. Отказ намеренно не 1: единицей Node завершается сам на +// необработанном исключении и ненайденном модуле, и сбой помощника читался +// бы как конфликт ветки. +// Личность коммитера берётся из окружения (конвейер задаёт GIT_AUTHOR_* и +// GIT_COMMITTER_*) либо из `gitPrefix` (`-c user.name=…`) при вызове из кода. + +import { spawnSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { isMainModule } from './spawn-portable.mjs'; + +export const REVIEWS_INDEX_PATH = 'docs/reviews/INDEX.md'; +/** Скрипт индекса — по абсолютному пути: ребейз идёт и из чужого cwd (worktree кандидата). */ +export const REVIEWS_INDEX_SCRIPT = fileURLToPath(new URL('./reviews-index.mjs', import.meta.url)); +/** Предохранитель от зацикливания: коммитов в ветке задачи единицы, не тысячи. */ +export const MAX_STOPS = 500; +/** Код выхода CLI «ребейз отменён, вот конфликты». */ +export const EXIT_CONFLICT = 3; + +const uniquePaths = (paths) => [...new Set(paths.map((p) => String(p).trim()).filter(Boolean))].sort(); + +/** + * Чистое решение по одной остановке ребейза. + * + * @param {string[]} paths конфликтующие пути (`git diff --name-only --diff-filter=U`) + * @param {{ extra?: (path: string) => boolean }} [opts] пути, которые вызывающий решает сам + * @returns {{ action: 'resolve', index: boolean, extra: string[], conflicts: string[] } + * | { action: 'abort', reason: 'no-conflicts'|'manual', manual: string[], conflicts: string[] }} + */ +export function planStop(paths, { extra = () => false } = {}) { + const conflicts = uniquePaths(paths); + if (!conflicts.length) return { action: 'abort', reason: 'no-conflicts', manual: [], conflicts }; + const manual = conflicts.filter((path) => path !== REVIEWS_INDEX_PATH && !extra(path)); + if (manual.length) return { action: 'abort', reason: 'manual', manual, conflicts }; + return { + action: 'resolve', + index: conflicts.includes(REVIEWS_INDEX_PATH), + extra: conflicts.filter((path) => path !== REVIEWS_INDEX_PATH), + conflicts, + }; +} + +/** git-исполнитель: `(args, { allowFailure }) => { ok, status, stdout, stderr }`. */ +export function makeGit({ cwd = process.cwd(), env = process.env, gitPrefix = [] } = {}) { + const run = (args, { allowFailure = false } = {}) => { + const r = spawnSync('git', [...gitPrefix, ...args], { + cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], + // Редактор сообщения на `rebase --continue` не открывается никогда. + env: { ...env, GIT_EDITOR: 'true' }, + }); + if (r.error) throw r.error; + const out = { ok: r.status === 0, status: r.status, stdout: (r.stdout || '').trim(), stderr: (r.stderr || '').trim() }; + if (!out.ok && !allowFailure) throw new Error(`git ${args.join(' ')} → ${out.stderr || out.stdout}`); + return out; + }; + run.cwd = cwd; + return run; +} + +/** Пересборка индекса по каталогу в дереве остановки — тем же генератором, что и конвейер. */ +export function rebuildIndexWith({ cwd = process.cwd(), env = process.env } = {}) { + return () => { + const r = spawnSync(process.execPath, [REVIEWS_INDEX_SCRIPT, '--dir=docs/reviews'], { + cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], env, + }); + if (r.error) throw r.error; + if (r.status !== 0) throw new Error(`reviews-index.mjs → ${(r.stderr || r.stdout || '').trim()}`); + }; +} + +const conflictedPaths = (git) => git(['diff', '--name-only', '--diff-filter=U']).stdout.split('\n'); +const rebaseInProgress = (git, cwd) => ['rebase-merge', 'rebase-apply'].some((name) => { + const probe = git(['rev-parse', '--git-path', name], { allowFailure: true }); + return probe.ok && existsSync(resolve(git.cwd || cwd, probe.stdout)); +}); + +/** + * Ребейз на `onto` с разрешением генерируемых конфликтов. + * + * Возвращает `{ ok: true, stops, resolved }` либо + * `{ ok: false, reason, conflicts, manual, output }` — во втором случае ребейз + * уже отменён. Сбой посреди ребейза (исключение) тоже отменяет его: дерево + * вызывающего не остаётся в полуребейзе. + */ +export function rebaseRegenerating({ + onto, cwd = process.cwd(), env = process.env, gitPrefix = [], + git = makeGit({ cwd, env, gitPrefix }), + rebuildIndex = rebuildIndexWith({ cwd, env }), + extra = null, log = () => {}, maxStops = MAX_STOPS, +}) { + if (!onto) throw new Error('rebaseRegenerating: не задано, на что ребейзить (onto)'); + const resolved = []; + let stops = 0; + const abort = (result) => { + git(['rebase', '--abort'], { allowFailure: true }); + return { ok: false, resolved, stops, ...result }; + }; + try { + let step = git(['rebase', onto], { allowFailure: true }); + while (!step.ok) { + stops += 1; + if (stops > maxStops) return abort({ reason: 'too-many-stops', conflicts: [], manual: [], output: step.stderr || step.stdout }); + const plan = planStop(conflictedPaths(git), { extra: extra?.match }); + if (plan.action === 'abort') { + return abort({ reason: plan.reason, conflicts: plan.conflicts, manual: plan.manual, output: step.stderr || step.stdout }); + } + for (const path of plan.extra) resolved.push(`${path} ← ${extra.resolve(path)}`); + if (plan.index) { + // Не --ours и не --theirs: ни одна сторона не знает документов другой. + rebuildIndex(); + git(['add', '--', REVIEWS_INDEX_PATH]); + resolved.push(`${REVIEWS_INDEX_PATH} ← пересборка`); + } + log(`остановка ${stops}: решено ${plan.conflicts.join(', ')}`); + // Коммит, ставший пустым после пересборки (индекс-коммит конвейера + // поверх уже пересобранного индекса), пропускается явно: что делает + // `--continue` с пустым результатом, зависит от версии git и бэкенда + // ребейза, а `--skip` при индексе, равном HEAD, не теряет ничего. + const empty = git(['diff', '--cached', '--quiet', 'HEAD'], { allowFailure: true }).ok; + step = git(['rebase', empty ? '--skip' : '--continue'], { allowFailure: true }); + } + return { ok: true, stops, resolved }; + } catch (error) { + if (rebaseInProgress(git, cwd)) git(['rebase', '--abort'], { allowFailure: true }); + throw error; + } +} + +if (isMainModule(import.meta.url)) { // #496: переносимо для Windows + const onto = process.argv.find((a) => a.startsWith('--onto='))?.slice('--onto='.length); + if (!onto) { + console.error('usage: rebase-generated.mjs --onto='); + process.exit(2); + } + try { + const result = rebaseRegenerating({ onto, log: (line) => console.error(line) }); + if (result.ok) { + console.error(`ребейз на ${onto} завершён; остановок решено: ${result.stops}${result.resolved.length ? ` (${result.resolved.join('; ')})` : ''}`); + process.exit(0); + } + if (result.conflicts.length) process.stdout.write(`${result.conflicts.join('\n')}\n`); + console.error(result.reason === 'manual' + ? `ребейз на ${onto} отменён: конфликт вне генерируемых путей — ${result.manual.join(', ')}` + : `ребейз на ${onto} отменён (${result.reason}):\n${result.output}`); + process.exit(EXIT_CONFLICT); + } catch (error) { + console.error(`rebase-generated: ${error.message}`); + process.exit(2); + } +} diff --git a/scripts/rebase-on-dev.mjs b/scripts/rebase-on-dev.mjs index 445f17a3..3fcace3e 100644 --- a/scripts/rebase-on-dev.mjs +++ b/scripts/rebase-on-dev.mjs @@ -7,7 +7,10 @@ // content-hashed чанки. Руками это не решается, решается пересборкой. Скрипт // делает ровно это: при конфликте ТОЛЬКО в сгенерированных путях берёт версию // dev, доводит ребейз до конца, пересобирает бандл (`npm run bundle:sync`) и, -// если он отличается, амендит последний коммит ветки. Конфликт в любом другом +// если он отличается, амендит последний коммит ветки. Индекс ревью +// `docs/reviews/INDEX.md` (#643) — тоже генерируемый: при конфликте он +// пересобирается по каталогу в дереве остановки (общий помощник +// `rebase-generated.mjs`, тот же, что у конвейера). Конфликт в любом другом // пути — останов с `git rebase --abort`: содержательные конфликты решает автор. // // node scripts/rebase-on-dev.mjs # ребейз текущей ветки @@ -20,17 +23,21 @@ import { existsSync, rmSync } from 'node:fs'; import { resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { portableCommand } from './spawn-portable.mjs'; +import { REVIEWS_INDEX_PATH, rebaseRegenerating } from './rebase-generated.mjs'; export const GENERATED_ROOTS = ['dist/', 'custom_components/houseplan/frontend/']; export const isGenerated = (path) => GENERATED_ROOTS.some((root) => path.startsWith(root)); +/** Генерируемые пути, которые решаются пересборкой в момент остановки, а не версией dev (#643). */ +export const REGENERATED_PATHS = [REVIEWS_INDEX_PATH]; +export const isRegenerated = (path) => REGENERATED_PATHS.includes(path); -/** Разделить конфликтующие пути: сгенерированные решаем сами, остальные — нет. */ +/** Разделить конфликтующие пути: бандл и индекс решаем сами, остальные — нет. */ export function splitConflicts(paths) { - const generated = []; const manual = []; + const generated = []; const regenerated = []; const manual = []; for (const path of paths.map((p) => p.trim()).filter(Boolean)) { - (isGenerated(path) ? generated : manual).push(path); + (isGenerated(path) ? generated : isRegenerated(path) ? regenerated : manual).push(path); } - return { generated, manual }; + return { generated, regenerated, manual }; } export function makeGit(cwd) { @@ -91,25 +98,23 @@ export function rebaseOnDev({ const both = theirs.filter((path) => ours.has(path)); const predicted = splitConflicts(both); if (predicted.generated.length) log(`бандл менялся с обеих сторон: ${predicted.generated.length} файл(ов) — решится пересборкой`); + if (predicted.regenerated.length) log(`индекс ревью менялся с обеих сторон — решится пересборкой по каталогу: ${predicted.regenerated.join(', ')}`); if (predicted.manual.length) log(`менялись с обеих сторон и НЕ сгенерированы (возможен ручной конфликт): ${predicted.manual.join(', ')}`); if (dryRun) { log('--dry-run: дерево не тронуто'); return { branch, rebased: false, resolved: [], rebuilt: false, predicted }; } - const resolved = []; - let step = git(['rebase', upstream], { allowFailure: true }); - while (!step.ok) { - const conflicts = git(['diff', '--name-only', '--diff-filter=U']).stdout.split('\n').filter(Boolean); - if (!conflicts.length) { - git(['rebase', '--abort'], { allowFailure: true }); - throw new Error(`rebase остановился без конфликтов:\n${step.stderr || step.stdout}`); + // Цикл остановок — общий с конвейером (#643): индекс ревью пересобирается + // помощником, бандл — версией dev здесь, всё прочее — отказ с abort. + const outcome = rebaseRegenerating({ + onto: upstream, cwd, git, + extra: { match: isGenerated, resolve: (path) => resolveGeneratedConflict(git, path) }, + }); + if (!outcome.ok) { + if (outcome.reason === 'manual') { + throw new Error(`конфликт вне сгенерированных путей — ребейз отменён, дерево как было:\n ${outcome.manual.join('\n ')}`); } - const { generated, manual } = splitConflicts(conflicts); - if (manual.length) { - git(['rebase', '--abort']); - throw new Error(`конфликт вне сгенерированных путей — ребейз отменён, дерево как было:\n ${manual.join('\n ')}`); - } - for (const path of generated) resolved.push(`${path} ← ${resolveGeneratedConflict(git, path)}`); - step = git(['rebase', '--continue'], { allowFailure: true }); + throw new Error(`rebase остановился без конфликтов (${outcome.reason}) — ребейз отменён:\n${outcome.output}`); } + const { resolved } = outcome; log(`ребейз завершён; сгенерированных конфликтов решено: ${resolved.length}`); // Пересборка: версия dev в бандле — не версия этой ветки. Собираем и, если diff --git a/test/merge-candidate.test.mjs b/test/merge-candidate.test.mjs index db7b2640..dec2e053 100755 --- a/test/merge-candidate.test.mjs +++ b/test/merge-candidate.test.mjs @@ -9,6 +9,11 @@ import { MAX_ATTEMPTS, MAX_COMMAND_OUTPUT_BYTES, commentFor, decideMerge, mergeCandidate, realOps, sh, } from '../scripts/merge-candidate.mjs'; import { buildCiProof } from '../scripts/ci-proof.mjs'; +import { buildIndex } from '../scripts/reviews-index.mjs'; + +// #643: сценарии на настоящем git ведут временные репозитории — GIT_* родителя +// (GIT_DIR из pre-push хука, урок #633) направили бы их в чужой репозиторий. +for (const key of Object.keys(process.env)) if (/^GIT_/i.test(key)) delete process.env[key]; import { jobInstanceNames, validateJobs } from '../scripts/workflow-jobs.mjs'; // #622: имена и число экземпляров job — из validate.yml, не копией строк. @@ -395,6 +400,82 @@ test('#516 AC1: dev moved only by review documents and the branch carries its ow } }); +// ---------- #643: doc-коммит ветки конфликтует с dev только в INDEX.md ---------- + +test('#643 AC1: dev сдвинулся документами ревью другой задачи, ветка несёт свой doc-коммит с INDEX — слияние через Validate, не S6', async () => { + const dir = mkdtempSync(join(tmpdir(), 'hp-merge-643-')); + try { + const origin = join(dir, 'origin.git'); + const work = join(dir, 'work'); + execFileSync('git', ['init', '-q', '--bare', origin]); + execFileSync('git', ['clone', '-q', origin, work]); + const cfg = ['-c', 'user.name=t', '-c', 'user.email=t@x']; + const git = (cwd, ...args) => execFileSync('git', ['-C', cwd, ...cfg, ...args], { encoding: 'utf8' }).trim(); + const reviews = join(work, 'docs', 'reviews'); + // Как делает шаг публикации: документ раунда и индекс, пересобранный по каталогу, — одним коммитом. + const publish = (name, msg) => { + writeFileSync(join(reviews, name), `# ${name}\nВердикт: **зелёный** · High: 0 · Medium: 0\n`); + writeFileSync(join(reviews, 'INDEX.md'), buildIndex(reviews)); + git(work, 'add', '-A'); + git(work, 'commit', '-q', '-m', msg); + }; + mkdirSync(reviews, { recursive: true }); + writeFileSync(join(work, 'a.mjs'), 'export const a = 20;\n'); + git(work, 'add', '.'); + publish('CODE-REVIEW-1-r1.md', 'base'); + git(work, 'branch', '-M', 'dev'); + git(work, 'push', '-q', '-u', 'origin', 'dev'); + git(work, 'checkout', '-q', '-b', 'issue/9-fix'); + writeFileSync(join(work, 'a.mjs'), 'export const a = 21;\n'); + git(work, 'commit', '-q', '-am', 'fix'); + const material = git(work, 'rev-parse', 'HEAD'); + publish('CODE-REVIEW-9-r1.md', 'docs: review document for #9'); + git(work, 'push', '-q', '-u', 'origin', 'issue/9-fix'); + git(work, 'checkout', '-q', 'dev'); + publish('CODE-REVIEW-8-r2.md', 'docs: review document for #8'); + git(work, 'push', '-q', 'origin', 'dev'); + git(work, 'checkout', '-q', 'issue/9-fix'); + // Предусловие: обычный ребейз на этом дереве действительно конфликтует в индексе. + const probe = spawnSync('git', ['-C', work, ...cfg, 'rebase', 'origin/dev'], { encoding: 'utf8' }); + assert.notEqual(probe.status, 0, 'без помощника doc-коммит конфликтует'); + assert.equal(git(work, 'diff', '--name-only', '--diff-filter=U'), 'docs/reviews/INDEX.md'); + git(work, 'rebase', '--abort'); + + const calls = []; + const ops = realOps({ repo: 'x/y', token: 'none', issue: 9 }); + ops.pushWithLease = (sha, ref, expected) => { + calls.push(['push', ref, expected]); + const r = spawnSync('git', ['-C', work, 'push', '-q', `--force-with-lease=refs/heads/${ref}:${expected}`, 'origin', `${sha}:refs/heads/${ref}`], { encoding: 'utf8' }); + return r.status === 0; + }; + ops.dispatchValidate = (ref) => { calls.push(['dispatch', ref]); }; + ops.waitValidate = async (sha) => { calls.push(['validate', sha]); return { result: 'green', url: 'https://run/1' }; }; + ops.comment = (issue, body) => { calls.push(['comment', body.slice(0, 60)]); }; + ops.log = () => {}; + const inWork = (fn) => (...args) => { const cwd = process.cwd(); process.chdir(work); try { return fn(...args); } finally { process.chdir(cwd); } }; + for (const name of ['fetch', 'revParse', 'mergeBase', 'diffNames', 'patchId', 'rebaseOnto']) ops[name] = inWork(ops[name]); + + const r = await mergeCandidate({ branch: 'issue/9-fix', material, issue: 9, ops }); + assert.equal(r.action, 'push', JSON.stringify(calls)); + assert.ok(!calls.some((c) => c[0] === 'comment' && /конфликтует/.test(c[1])), 'нет возврата в S6 «конфликтует с dev»'); + const validateAt = calls.findIndex((c) => c[0] === 'validate'); + const devPushAt = calls.findIndex((c) => c[0] === 'push' && c[1] === 'dev'); + assert.ok(validateAt >= 0 && validateAt < devPushAt, 'кандидат уходит в dev только после Validate'); + const devTip = git(work, 'rev-parse', 'origin/dev'); + assert.equal(devTip, r.candidate); + const index = `${git(work, 'show', `${devTip}:docs/reviews/INDEX.md`)}\n`; + git(work, 'checkout', '-q', devTip); + assert.equal(index, buildIndex(reviews), 'индекс кандидата = пересборка каталога'); + assert.match(index, /CODE-REVIEW-8-r2\.md/); + assert.match(index, /CODE-REVIEW-9-r1\.md/); + assert.match(git(work, 'show', `${devTip}:a.mjs`), /a = 21/); + // Личность конвейера на переписанных коммитах сохранена. + assert.equal(git(work, 'log', '-1', '--format=%cn', devTip), 'claude[bot]'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + // #596: шаг слияния считает patch-id через `git diff` кандидата, а тот несёт три // копии бандла — семь мегабайт у #594. С умолчанием spawnSync в 1 МиБ процесс // убивался по ENOBUFS, `status` приходил `null`, и вывод обрезался посередине. diff --git a/test/rebase-generated.test.mjs b/test/rebase-generated.test.mjs new file mode 100644 index 00000000..289a6fc6 --- /dev/null +++ b/test/rebase-generated.test.mjs @@ -0,0 +1,291 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { REVIEWS_INDEX_PATH, planStop, rebaseRegenerating } from '../scripts/rebase-generated.mjs'; +import { buildIndex } from '../scripts/reviews-index.mjs'; + +// #643: doc-коммит ветки задачи конфликтует с dev только в генерируемом +// `docs/reviews/INDEX.md` — ребейз решает это пересборкой; любой другой +// конфликт — прежний отказ с перечнем. Сценарии — настоящий git во временных +// репозиториях. +// +// Окружение git — без единой GIT_* переменной родителя (урок #633): тест, +// запущенный из pre-push хука, иначе унаследует GIT_DIR и будет ребейзить +// репозиторий хука, а не временный. Личность и переводы строк — своим +// окружением, глобальный конфиг владельца не трогается (#496). +const cleanGitEnv = () => ({ + ...Object.fromEntries(Object.entries(process.env).filter(([key]) => !/^GIT_/i.test(key))), + GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t', + GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null', + GIT_CONFIG_COUNT: '2', + GIT_CONFIG_KEY_0: 'core.autocrlf', GIT_CONFIG_VALUE_0: 'false', + GIT_CONFIG_KEY_1: 'core.eol', GIT_CONFIG_VALUE_1: 'lf', +}); +const ENV = cleanGitEnv(); +if (process.platform === 'win32') delete ENV.GIT_CONFIG_GLOBAL; +const git = (cwd, ...args) => execFileSync('git', args, { + cwd, encoding: 'utf8', env: ENV, stdio: ['ignore', 'pipe', 'pipe'], +}).trim(); + +const REVIEWS = 'docs/reviews'; +const doc = (work, name, verdict = 'зелёный') => writeFileSync(join(work, REVIEWS, name), `# ${name}\nВердикт: **${verdict}** · High: 0 · Medium: 0\n`); +const reindex = (work) => writeFileSync(join(work, REVIEWS_INDEX_PATH), buildIndex(join(work, REVIEWS))); +const commitAll = (work, msg) => { git(work, 'add', '-A'); git(work, 'commit', '-q', '-m', msg); }; + +/** + * dev: base (документ #1 + индекс) → документ #8 со своим индексом. + * ветка issue/9-fix от base: правка кода → документ #9 со своим индексом. + * `shared` — вдобавок оба doc-коммита правят один и тот же обычный файл. + */ +const SCRIPTS = fileURLToPath(new URL('../scripts/', import.meta.url)); + +/** + * Замыкание модуля: относительные импорты и скрипты, которые он запускает по + * `new URL('./x.mjs', import.meta.url)` (генератор индекса), — рекурсивно. + */ +function importClosure(entry, seen = new Set()) { + if (seen.has(entry)) return seen; + seen.add(entry); + const text = readFileSync(entry, 'utf8'); + const specs = [ + ...text.matchAll(/^import[^'"]*['"](\.{1,2}\/[^'"]+)['"]/gm), + ...text.matchAll(/new URL\('(\.{1,2}\/[^']+\.mjs)', import\.meta\.url\)/g), + ].map((m) => m[1]); + for (const spec of specs) importClosure(resolve(dirname(entry), spec), seen); + return seen; +} + +function scenario({ shared = false, tools = false } = {}) { + const root = mkdtempSync(join(tmpdir(), 'hp-rebase-gen-')); + const origin = join(root, 'origin.git'); const work = join(root, 'work'); + git(root, 'init', '--bare', '-q', origin); + git(root, 'clone', '-q', origin, work); + git(work, 'checkout', '-q', '-b', 'dev'); + mkdirSync(join(work, REVIEWS), { recursive: true }); + writeFileSync(join(work, 'a.mjs'), 'export const a = 20;\n'); + writeFileSync(join(work, 'shared.txt'), 'base\n'); + doc(work, 'CODE-REVIEW-1-r1.md'); + reindex(work); + if (tools) { + // Шаг конвейера берёт помощника из dev: dev временного репозитория несёт + // его вместе с замыканием импортов. + mkdirSync(join(work, 'scripts')); + for (const file of importClosure(join(SCRIPTS, 'rebase-generated.mjs'))) { + // 'broken' — dev без зависимости помощника: Node упадёт на импорте с кодом 1. + if (tools === 'broken' && file.endsWith('spawn-portable.mjs')) continue; + copyFileSync(file, join(work, 'scripts', file.slice(SCRIPTS.length))); + } + } + commitAll(work, 'base'); + git(work, 'push', '-q', '-u', 'origin', 'dev'); + + git(work, 'checkout', '-q', '-b', 'issue/9-fix'); + writeFileSync(join(work, 'a.mjs'), 'export const a = 21;\n'); + commitAll(work, 'fix'); + doc(work, 'CODE-REVIEW-9-r1.md'); + reindex(work); + if (shared) writeFileSync(join(work, 'shared.txt'), 'branch\n'); + commitAll(work, 'docs: review document for #9'); + const branchIndex = readFileSync(join(work, REVIEWS_INDEX_PATH), 'utf8'); + + git(work, 'checkout', '-q', 'dev'); + doc(work, 'CODE-REVIEW-8-r1.md', 'жёлтый'); + reindex(work); + if (shared) writeFileSync(join(work, 'shared.txt'), 'dev\n'); + commitAll(work, 'docs: review document for #8'); + git(work, 'push', '-q', 'origin', 'dev'); + const devIndex = readFileSync(join(work, REVIEWS_INDEX_PATH), 'utf8'); + git(work, 'checkout', '-q', 'issue/9-fix'); + return { root, work, branchIndex, devIndex }; +} + +test('#643 planStop: разрешается только набор, где ВСЕ конфликты — индекс или объявленные вызывающим', () => { + assert.deepEqual(planStop([REVIEWS_INDEX_PATH, '', ` ${REVIEWS_INDEX_PATH}`]), + { action: 'resolve', index: true, extra: [], conflicts: [REVIEWS_INDEX_PATH] }); + const mixed = planStop([REVIEWS_INDEX_PATH, 'src/x.ts']); + assert.equal(mixed.action, 'abort'); + assert.equal(mixed.reason, 'manual'); + assert.deepEqual(mixed.manual, ['src/x.ts']); + assert.deepEqual(mixed.conflicts, [REVIEWS_INDEX_PATH, 'src/x.ts'], 'в перечне отказа — все пути, индекс тоже'); + assert.equal(planStop(['docs/reviews/CODE-REVIEW-9-r1.md']).action, 'abort', 'документ ревью — не генерируемый файл'); + assert.equal(planStop(['docs/reviews/sub/INDEX.md']).action, 'abort', 'другой INDEX.md — не индекс ревью'); + assert.deepEqual(planStop([]), { action: 'abort', reason: 'no-conflicts', manual: [], conflicts: [] }); + const bundle = planStop(['dist/a.js', REVIEWS_INDEX_PATH], { extra: (p) => p.startsWith('dist/') }); + assert.deepEqual(bundle, { action: 'resolve', index: true, extra: ['dist/a.js'], conflicts: ['dist/a.js', REVIEWS_INDEX_PATH] }); +}); + +test('#643 AC1: конфликт только в INDEX.md — ребейз проходит, индекс равен пересборке каталога', () => { + const { root, work, branchIndex, devIndex } = scenario(); + try { + const result = rebaseRegenerating({ onto: 'origin/dev', cwd: work, env: ENV }); + assert.equal(result.ok, true, JSON.stringify(result)); + assert.ok(result.stops >= 1, 'конфликт действительно был — иначе тест ничего не доказывает'); + assert.ok(result.resolved.includes(`${REVIEWS_INDEX_PATH} ← пересборка`)); + assert.equal(git(work, 'status', '--porcelain'), ''); + assert.equal(git(work, 'rev-list', '--count', 'HEAD..origin/dev'), '0', 'dev целиком под веткой'); + assert.equal(git(work, 'rev-list', '--count', 'origin/dev..HEAD'), '2', 'оба коммита ветки на месте'); + const index = git(work, 'show', `HEAD:${REVIEWS_INDEX_PATH}`) + '\n'; + assert.equal(index, buildIndex(join(work, REVIEWS)), 'индекс = пересборка по каталогу'); + assert.notEqual(index, devIndex, 'не версия dev'); + assert.notEqual(index, branchIndex, 'не версия ветки'); + assert.match(index, /CODE-REVIEW-8-r1\.md/); + assert.match(index, /CODE-REVIEW-9-r1\.md/); + assert.match(git(work, 'show', 'HEAD:a.mjs'), /a = 21/); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +test('#643 AC2: INDEX.md вместе с другим файлом — отказ с обоими путями, дерево и HEAD как были', () => { + const { root, work } = scenario({ shared: true }); + try { + const before = git(work, 'rev-parse', 'HEAD'); + const result = rebaseRegenerating({ onto: 'origin/dev', cwd: work, env: ENV }); + assert.equal(result.ok, false); + assert.equal(result.reason, 'manual'); + assert.deepEqual(result.conflicts, [REVIEWS_INDEX_PATH, 'shared.txt']); + assert.deepEqual(result.manual, ['shared.txt']); + assert.equal(git(work, 'rev-parse', 'HEAD'), before); + assert.equal(git(work, 'status', '--porcelain'), ''); + assert.equal(existsSync(join(work, '.git', 'rebase-merge')), false, 'ребейз отменён, не брошен'); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +test('#643: индекс-коммит, ставший пустым после пересборки, пропускается, а не останавливает ребейз', () => { + const { root, work } = scenario(); + try { + // Индекс-коммит поверх doc-коммита: шапка старого генератора. После + // ребейза пересборка совпадает с индексом предыдущего коммита. + const path = join(work, REVIEWS_INDEX_PATH); + writeFileSync(path, readFileSync(path, 'utf8').replace('Генерируется', 'Сгенерирован старым генератором')); + commitAll(work, 'docs(reviews): индекс после сдвига каталога (#9)'); + const result = rebaseRegenerating({ onto: 'origin/dev', cwd: work, env: ENV }); + assert.equal(result.ok, true, JSON.stringify(result)); + assert.equal(result.stops, 2); + assert.equal(git(work, 'rev-list', '--count', 'origin/dev..HEAD'), '2', 'пустой индекс-коммит отброшен'); + assert.equal(git(work, 'show', `HEAD:${REVIEWS_INDEX_PATH}`) + '\n', buildIndex(join(work, REVIEWS))); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +test('#643: сбой пересборки посреди ребейза отменяет ребейз, а не оставляет дерево в полуребейзе', () => { + const { root, work } = scenario(); + try { + const before = git(work, 'rev-parse', 'HEAD'); + assert.throws(() => rebaseRegenerating({ + onto: 'origin/dev', cwd: work, env: ENV, rebuildIndex: () => { throw new Error('генератор упал'); }, + }), /генератор упал/); + assert.equal(git(work, 'rev-parse', 'HEAD'), before); + assert.equal(git(work, 'status', '--porcelain'), ''); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +test('#643 CLI: отказ — код 3 и по строке на конфликтующий путь в stdout', () => { + const { root, work } = scenario({ shared: true }); + try { + const script = fileURLToPath(new URL('../scripts/rebase-generated.mjs', import.meta.url)); + let failure; + try { + execFileSync(process.execPath, [script, '--onto=origin/dev'], + { cwd: work, env: ENV, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); + } catch (error) { failure = error; } + assert.ok(failure, 'отказ — ненулевой код'); + assert.equal(failure.status, 3, 'не 1: единицей Node выходит сам на сбое'); + assert.equal(failure.stdout, `${REVIEWS_INDEX_PATH}\nshared.txt\n`); + assert.match(failure.stderr, /конфликт вне генерируемых путей — shared\.txt/); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +// ---------- проводка в process.yml: свидетели и настоящий bash ---------- + +const WORKFLOW = readFileSync(new URL('../.github/workflows/process.yml', import.meta.url), 'utf8'); +const rebaseStep = () => WORKFLOW.slice( + WORKFLOW.indexOf(' - name: Привести ветку к dev\n'), + WORKFLOW.indexOf(' - name: Зафиксировать SHA материала ревью'), +); + +test('#643 process.yml: шаг «Привести ветку к dev» ребейзит помощником из dev и сохраняет прежние гарантии', () => { + const step = rebaseStep(); + assert.ok(step.length > 0, 'шаг найден'); + assert.doesNotMatch(step, /^\s+if ! git rebase origin\/dev/m, 'голого git rebase больше нет'); + assert.match(step, /git archive origin\/dev scripts \| tar -x -C "\$tools"/, 'помощник — из dev, не из отставшей ветки'); + assert.match(step, /files=\$\(node "\$tools\/scripts\/rebase-generated\.mjs" --onto=origin\/dev\) \|\| code=\$\?/); + assert.match(step, /if \[ "\$code" -ne 0 \] && \[ "\$code" -ne 3 \]; then/, 'сбой помощника — не конфликт'); + assert.match(step, /echo 'conflict=true'\n\s+echo 'conflicts< step.indexOf(needle)); + assert.ok(at.every((i) => i >= 0), JSON.stringify(at)); + assert.deepEqual([...at].sort((a, b) => a - b), at, 'порядок шагов сохранён'); +}); + +test('#643: замыкание импортов помощника не выходит из scripts/ — архива scripts достаточно', () => { + const closure = importClosure(join(SCRIPTS, 'rebase-generated.mjs')); + assert.ok(closure.has(join(SCRIPTS, 'reviews-index.mjs')), 'генератор индекса в замыкании'); + for (const file of closure) { + assert.ok(file.startsWith(SCRIPTS), `${file} вне scripts/`); + } +}); + +/** Исполнить ребейзную часть шага как есть (bash -eo pipefail, как у Actions). */ +function runStepRebase(work) { + const step = rebaseStep(); + const body = step.slice(step.indexOf(' run: |\n') + ' run: |\n'.length) + .split('\n').map((line) => line.replace(/^ {10}/, '')).join('\n'); + const from = body.indexOf('tools="$RUNNER_TEMP/rebase-tools"'); + const to = body.indexOf('# #635 r2:'); + assert.ok(from >= 0 && to > from, 'ребейзная часть шага найдена'); + const temp = mkdtempSync(join(tmpdir(), 'hp-runner-')); + const output = join(temp, 'output'); + writeFileSync(output, ''); + const script = `${body.slice(from, to)}\necho REBASED\n`; + const r = spawnSync('bash', ['--noprofile', '--norc', '-eo', 'pipefail', '-c', script], { + cwd: work, encoding: 'utf8', env: { ...ENV, RUNNER_TEMP: temp, GITHUB_OUTPUT: output, BRANCH: 'issue/9-fix' }, + }); + const result = { status: r.status, stdout: r.stdout, stderr: r.stderr, output: readFileSync(output, 'utf8') }; + rmSync(temp, { recursive: true, force: true }); + return result; +} + +const hasBash = () => process.platform !== 'win32' && spawnSync('bash', ['--version']).status === 0 + && spawnSync('tar', ['--version']).status === 0; + +test('#643 process.yml на настоящем bash: конфликт только в индексе — ветка приведена, conflict не выставлен', (t) => { + if (!hasBash()) { t.skip('bash/tar недоступны'); return; } + const { root, work } = scenario({ tools: true }); + try { + const r = runStepRebase(work); + assert.equal(r.status, 0, r.stderr); + assert.match(r.stdout, /REBASED/); + assert.equal(r.output, '', 'conflict не выставлен'); + assert.equal(git(work, 'rev-list', '--count', 'HEAD..origin/dev'), '0'); + assert.equal(git(work, 'show', `HEAD:${REVIEWS_INDEX_PATH}`) + '\n', buildIndex(join(work, REVIEWS))); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +test('#643 process.yml на настоящем bash: индекс + другой файл — conflict=true и оба пути в conflicts', (t) => { + if (!hasBash()) { t.skip('bash/tar недоступны'); return; } + const { root, work } = scenario({ tools: true, shared: true }); + try { + const before = git(work, 'rev-parse', 'HEAD'); + const r = runStepRebase(work); + assert.equal(r.status, 0, r.stderr); + assert.doesNotMatch(r.stdout, /REBASED/, 'шаг завершился на отказе'); + assert.equal(r.output, `conflict=true\nconflicts< { + if (!hasBash()) { t.skip('bash/tar недоступны'); return; } + const { root, work } = scenario({ tools: 'broken' }); + try { + const r = runStepRebase(work); + assert.equal(r.status, 1); + assert.equal(r.output, '', 'сбой не выдаётся за конфликт ветки'); + assert.match(r.stdout, /::error::помощник ребейза упал/); + } finally { rmSync(root, { recursive: true, force: true }); } +}); diff --git a/test/rebase-on-dev.test.mjs b/test/rebase-on-dev.test.mjs index bc809e57..2a99ebbf 100644 --- a/test/rebase-on-dev.test.mjs +++ b/test/rebase-on-dev.test.mjs @@ -5,6 +5,7 @@ import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'nod import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { rebaseOnDev, splitConflicts } from '../scripts/rebase-on-dev.mjs'; +import { buildIndex } from '../scripts/reviews-index.mjs'; // #479 AC5: конфликт только в бандле решается пересборкой, конфликт в src/** // останавливает ребейз, не тронув дерево. Сценарий — настоящий git в temp. @@ -14,6 +15,9 @@ import { rebaseOnDev, splitConflicts } from '../scripts/rebase-on-dev.mjs'; // Windows с глобальным `core.autocrlf=true` checkout давал `dev\r\n` вместо // `dev\n`, и тест краснел на переводе строки, а не на ребейзе. Менять глобальный // конфиг владельца ради теста нельзя — конфиг передаётся окружением. +// #643: GIT_* родителя снимаются целиком (урок #633) — запущенный из pre-push +// хука тест иначе унаследует GIT_DIR и ребейзит репозиторий хука. +for (const key of Object.keys(process.env)) if (/^GIT_/i.test(key)) delete process.env[key]; Object.assign(process.env, { GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t', GIT_CONFIG_COUNT: '2', @@ -28,7 +32,7 @@ const git = (cwd, ...args) => execFileSync('git', args, { const SYNC = [process.execPath, '-e', "const fs=require('fs');fs.writeFileSync('dist/a.js','built:'+fs.readFileSync('src/x.ts','utf8'))"]; -function repo({ conflictInSrc }) { +function repo({ conflictInSrc, reviews = false }) { const root = mkdtempSync(join(tmpdir(), 'hp-rebase-')); const origin = join(root, 'origin.git'); const work = join(root, 'work'); git(root, 'init', '--bare', '-q', '-b', 'dev', origin); @@ -38,21 +42,32 @@ function repo({ conflictInSrc }) { writeFileSync(join(work, 'src/x.ts'), 'base\n'); writeFileSync(join(work, 'src/y.ts'), 'y0\n'); writeFileSync(join(work, 'dist/a.js'), 'built:base\n'); + if (reviews) review(work, 1); git(work, 'add', '-A'); git(work, 'commit', '-q', '-m', 'base'); git(work, 'push', '-q', '-u', 'origin', 'dev'); // Ветка задачи: правит src/x.ts (или src/y.ts) и бандл. git(work, 'checkout', '-q', '-b', 'issue/1-x'); writeFileSync(join(work, conflictInSrc ? 'src/y.ts' : 'src/x.ts'), 'branch\n'); writeFileSync(join(work, 'dist/a.js'), 'built:branch\n'); + if (reviews) review(work, 9); git(work, 'add', '-A'); git(work, 'commit', '-q', '-m', 'feat: branch'); // dev уходит вперёд: другой файл (или тот же y.ts) и тот же бандл. git(work, 'checkout', '-q', 'dev'); writeFileSync(join(work, conflictInSrc ? 'src/y.ts' : 'src/z.ts'), 'dev\n'); writeFileSync(join(work, 'dist/a.js'), 'built:dev\n'); + if (reviews) review(work, 8); git(work, 'add', '-A'); git(work, 'commit', '-q', '-m', 'dev moves'); git(work, 'push', '-q', 'origin', 'dev'); git(work, 'checkout', '-q', 'issue/1-x'); return { root, work }; } +// Документ ревью задачи N и индекс, пересобранный по каталогу, — как коммитит конвейер. +function review(work, n) { + const dir = join(work, 'docs', 'reviews'); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, `CODE-REVIEW-${n}-r1.md`), `# CODE-REVIEW-${n}-r1\nВердикт: **зелёный** · High: 0 · Medium: 0\n`); + writeFileSync(join(dir, 'INDEX.md'), buildIndex(dir)); +} + test('splitConflicts делит пути на сгенерированные и ручные (#479)', () => { const { generated, manual } = splitConflicts([ 'dist/houseplan-card.js', 'custom_components/houseplan/frontend/houseplan-assets.json', @@ -60,6 +75,26 @@ test('splitConflicts делит пути на сгенерированные и ]); assert.deepEqual(generated, ['dist/houseplan-card.js', 'custom_components/houseplan/frontend/houseplan-assets.json']); assert.deepEqual(manual, ['src/houseplan-card.ts', 'custom_components/houseplan/const.py']); + // #643: индекс ревью — генерируемый путь рядом с бандлом, прочие docs — нет. + assert.deepEqual(splitConflicts(['docs/reviews/INDEX.md', 'docs/reviews/CODE-REVIEW-9-r1.md']), + { generated: [], regenerated: ['docs/reviews/INDEX.md'], manual: ['docs/reviews/CODE-REVIEW-9-r1.md'] }); +}); + +test('#643 AC3: бандл и INDEX.md конфликтуют в одном коммите — бандл пересобран, индекс = пересборка каталога', () => { + const { root, work } = repo({ conflictInSrc: false, reviews: true }); + try { + const result = rebaseOnDev({ cwd: work, syncCommand: SYNC, log: () => {} }); + assert.equal(result.rebased, true); + assert.ok(result.resolved.includes('docs/reviews/INDEX.md ← пересборка'), JSON.stringify(result.resolved)); + assert.ok(result.resolved.some((r) => r.startsWith('dist/a.js')), 'бандл решён в той же остановке'); + assert.equal(git(work, 'status', '--porcelain'), ''); + assert.equal(git(work, 'rev-list', '--count', 'HEAD..origin/dev'), '0'); + const index = git(work, 'show', 'HEAD:docs/reviews/INDEX.md') + '\n'; + assert.equal(index, buildIndex(join(work, 'docs', 'reviews')), 'индекс = пересборка, не версия dev и не ветки'); + assert.match(index, /CODE-REVIEW-8-r1\.md/); + assert.match(index, /CODE-REVIEW-9-r1\.md/); + assert.equal(readFileSync(join(work, 'dist/a.js'), 'utf8'), 'built:branch\n'); + } finally { rmSync(root, { recursive: true, force: true }); } }); test('конфликт только в бандле: ребейз доведён, бандл пересобран и зааменден (#479 AC5)', () => {