Files
houseplan-card/scripts/rebase-generated.mjs
T
Claude 92b83d9525 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
2026-09-24 09:35:58 +03:00

173 lines
9.9 KiB
JavaScript
Executable File

#!/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);
}
}