mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-28 19:01:34 +00:00
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
173 lines
9.9 KiB
JavaScript
Executable File
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);
|
|
}
|
|
}
|