mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +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
150 lines
9.5 KiB
JavaScript
150 lines
9.5 KiB
JavaScript
#!/usr/bin/env node
|
||
// Ребейз ветки задачи на origin/dev без ручных конфликтов в бандле (#479).
|
||
//
|
||
// Бандл лежит в репозитории (класс D: dist/**, custom_components/houseplan/
|
||
// frontend/**), поэтому две задачи, собравшие его параллельно, конфликтуют на
|
||
// нём всегда — 1.16 МБ минифицированного текста плюс переименованные
|
||
// content-hashed чанки. Руками это не решается, решается пересборкой. Скрипт
|
||
// делает ровно это: при конфликте ТОЛЬКО в сгенерированных путях берёт версию
|
||
// dev, доводит ребейз до конца, пересобирает бандл (`npm run bundle:sync`) и,
|
||
// если он отличается, амендит последний коммит ветки. Индекс ревью
|
||
// `docs/reviews/INDEX.md` (#643) — тоже генерируемый: при конфликте он
|
||
// пересобирается по каталогу в дереве остановки (общий помощник
|
||
// `rebase-generated.mjs`, тот же, что у конвейера). Конфликт в любом другом
|
||
// пути — останов с `git rebase --abort`: содержательные конфликты решает автор.
|
||
//
|
||
// node scripts/rebase-on-dev.mjs # ребейз текущей ветки
|
||
// node scripts/rebase-on-dev.mjs --dry-run # только план, дерево не трогается
|
||
//
|
||
// Дерево должно быть чистым. Ветка `dev` сама себя не ребейзит.
|
||
|
||
import { spawnSync } from 'node:child_process';
|
||
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 regenerated = []; const manual = [];
|
||
for (const path of paths.map((p) => p.trim()).filter(Boolean)) {
|
||
(isGenerated(path) ? generated : isRegenerated(path) ? regenerated : manual).push(path);
|
||
}
|
||
return { generated, regenerated, manual };
|
||
}
|
||
|
||
export function makeGit(cwd) {
|
||
return (args, { allowFailure = false, input } = {}) => {
|
||
const result = spawnSync('git', args, {
|
||
cwd, encoding: 'utf8', input, stdio: ['pipe', 'pipe', 'pipe'],
|
||
env: { ...process.env, GIT_EDITOR: 'true' },
|
||
});
|
||
if (result.error) throw result.error;
|
||
if (result.status !== 0 && !allowFailure) {
|
||
throw new Error(`git ${args.join(' ')} → ${(result.stderr || result.stdout || '').trim()}`);
|
||
}
|
||
return { ok: result.status === 0, stdout: (result.stdout || '').trim(), stderr: (result.stderr || '').trim() };
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Во время rebase «ours» — это upstream (dev), «theirs» — переигрываемый
|
||
* коммит ветки. Для сгенерированного пути берём dev: если в dev файла нет
|
||
* (чанк переименован), путь удаляется — пересборка вернёт актуальное имя.
|
||
*/
|
||
export function resolveGeneratedConflict(git, path) {
|
||
const inOurs = git(['cat-file', '-e', `:2:${path}`], { allowFailure: true }).ok;
|
||
if (inOurs) {
|
||
git(['checkout', '--ours', '--', path]);
|
||
git(['add', '--', path]);
|
||
return 'dev';
|
||
}
|
||
git(['rm', '--cached', '-f', '--quiet', '--', path], { allowFailure: true });
|
||
rmSync(resolve(git.cwd, path), { force: true });
|
||
return 'removed';
|
||
}
|
||
|
||
export function rebaseOnDev({
|
||
cwd = process.cwd(), upstream = 'origin/dev', dryRun = false,
|
||
syncCommand = ['npm', 'run', 'bundle:sync'], log = console.log, fetch = true,
|
||
} = {}) {
|
||
const git = Object.assign(makeGit(cwd), { cwd });
|
||
const dirty = git(['status', '--porcelain']).stdout;
|
||
if (dirty) throw new Error(`рабочее дерево не чистое — закоммитьте или спрятайте изменения:\n${dirty}`);
|
||
const branch = git(['rev-parse', '--abbrev-ref', 'HEAD']).stdout;
|
||
if (branch === 'HEAD') throw new Error('detached HEAD: ребейзится ветка, не коммит');
|
||
if (branch === 'dev' || branch === 'main') throw new Error(`ветка ${branch} не ребейзится этим скриптом`);
|
||
if (fetch) {
|
||
const [remote, ...rest] = upstream.split('/');
|
||
git(['fetch', '--quiet', remote, rest.join('/')]);
|
||
}
|
||
const base = git(['merge-base', upstream, 'HEAD']).stdout;
|
||
const ahead = Number(git(['rev-list', '--count', `${upstream}..HEAD`]).stdout);
|
||
const behind = Number(git(['rev-list', '--count', `HEAD..${upstream}`]).stdout);
|
||
log(`ветка ${branch}: впереди ${upstream} на ${ahead}, позади на ${behind}`);
|
||
if (behind === 0) { log('ребейз не нужен'); return { branch, rebased: false, resolved: [], rebuilt: false }; }
|
||
|
||
// Предсказание конфликтов по сгенерированным путям: файлы, которые менялись
|
||
// по обе стороны от merge-base. Точный список даёт только сам ребейз.
|
||
const ours = new Set(git(['diff', '--name-only', base, 'HEAD']).stdout.split('\n').filter(Boolean));
|
||
const theirs = git(['diff', '--name-only', base, upstream]).stdout.split('\n').filter(Boolean);
|
||
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 }; }
|
||
|
||
// Цикл остановок — общий с конвейером (#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 ')}`);
|
||
}
|
||
throw new Error(`rebase остановился без конфликтов (${outcome.reason}) — ребейз отменён:\n${outcome.output}`);
|
||
}
|
||
const { resolved } = outcome;
|
||
log(`ребейз завершён; сгенерированных конфликтов решено: ${resolved.length}`);
|
||
|
||
// Пересборка: версия dev в бандле — не версия этой ветки. Собираем и, если
|
||
// бандл отличается, амендим последний коммит ветки.
|
||
const [cmd, ...args] = syncCommand;
|
||
// Оболочка только для npm.cmd на Windows (#496): `node -e "…"` из теста и
|
||
// любая команда с кавычками через shell разваливаются.
|
||
const portable = portableCommand(cmd);
|
||
const sync = spawnSync(portable.cmd, args, { cwd, stdio: 'inherit', shell: portable.shell });
|
||
if (sync.status !== 0) throw new Error(`${syncCommand.join(' ')} завершился с кодом ${sync.status}; ребейз сделан, бандл не закоммичен`);
|
||
git(['add', '-A', '--', ...GENERATED_ROOTS.filter((root) => existsSync(resolve(cwd, root)))]);
|
||
const staged = git(['diff', '--cached', '--name-only']).stdout;
|
||
const rebuilt = staged.length > 0;
|
||
if (rebuilt) {
|
||
git(['commit', '--amend', '--no-edit', '--quiet']);
|
||
log(`бандл пересобран и добавлен в последний коммит (${staged.split('\n').length} файл(ов))`);
|
||
} else {
|
||
log('бандл после пересборки совпал с dev — амендить нечего');
|
||
}
|
||
return { branch, rebased: true, resolved, rebuilt };
|
||
}
|
||
|
||
const invokedDirectly = process.argv[1]
|
||
&& resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
|
||
if (invokedDirectly) {
|
||
try {
|
||
rebaseOnDev({ dryRun: process.argv.includes('--dry-run') });
|
||
} catch (error) {
|
||
console.error(`rebase-on-dev: ${error.message}`);
|
||
process.exitCode = 1;
|
||
}
|
||
}
|