fix(process): rebase resolves a conflict only in docs/reviews/INDEX.md by rebuilding the index

A pipeline doc commit carries the review document and the rebuilt
INDEX.md; while the task waits, dev receives other tasks' documents with
their own INDEX.md, and the rebase of the branch conflicts in the index
every time. 24.09 this bounced green #617, #618, #629, #642 to S6.

scripts/rebase-generated.mjs: shared rebase helper. At every stop, if ALL
conflicting paths are docs/reviews/INDEX.md (or paths the caller
resolves itself), the index is rebuilt from the directory in the stop
tree, staged, and the rebase continues; any other path aborts and
returns the full list. CLI exit 3 = refusal with paths on stdout.

Wired into process.yml «Привести ветку к dev» (helper taken from dev via
git archive; conflict/conflicts outputs, lease, ref wait and
--commit-if-stale kept), merge-candidate rebaseOnto (claude[bot]
identity, --commit-if-stale kept) and rebase-on-dev.mjs (index next to
GENERATED_ROOTS; bundle still dev copy + rebuild).

Issue: #643
User-Visible: no
This commit is contained in:
Claude
2026-09-24 09:35:58 +03:00
parent ee4bbf8f1f
commit 92b83d9525
10 changed files with 718 additions and 32 deletions
+12 -2
View File
@@ -26,6 +26,9 @@ import { fileURLToPath } from 'node:url';
import {
CI_PROOF_POLICIES, evaluateCiProof, githubCandidateTree, loadGithubProofContext,
} from './ci-proof.mjs';
import { rebaseRegenerating } from './rebase-generated.mjs';
// Личность конвейера — одна на ребейз и на коммит индекса (#643).
import { CONVEYOR_IDENTITY } from './reviews-index.mjs';
/** Скрипт индекса — по абсолютному пути: слияние работает из чужого cwd (worktree кандидата). */
const REVIEWS_INDEX_SCRIPT = fileURLToPath(new URL('./reviews-index.mjs', import.meta.url));
@@ -148,8 +151,15 @@ export function realOps({
},
rebaseOnto: (branchTip, onto) => {
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`), а тест «индекс свеж» в
+69
View File
@@ -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. Каждая защита — от тихого
// расхождения выжимки с каноном или тихого роста входа.
+172
View File
@@ -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=<ref>');
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);
}
}
+24 -19
View File
@@ -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 в бандле — не версия этой ветки. Собираем и, если