mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
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:
@@ -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<<EOF_FILES'
|
||||
|
||||
@@ -184,8 +184,10 @@ remaining work is the rebase. When the conflict is only in the committed bundle
|
||||
(`dist/**`, `custom_components/houseplan/frontend/**` — the usual case when two
|
||||
tasks built it in parallel), run `node scripts/rebase-on-dev.mjs` (#479): it takes
|
||||
`dev`'s copy through the rebase, rebuilds with `npm run bundle:sync` and amends
|
||||
the result into your last commit; a conflict anywhere else aborts and leaves the
|
||||
tree as it was. Then push the branch and re-apply `S7-code-review`. When the
|
||||
the result into your last commit; a conflict in `docs/reviews/INDEX.md` is
|
||||
rebuilt from the directory (#643, the same helper the pipeline uses, so the
|
||||
pipeline no longer bounces a task on it); a conflict anywhere else aborts and
|
||||
leaves the tree as it was. Then push the branch and re-apply `S7-code-review`. When the
|
||||
only difference from the reviewed material is the pipeline's own review-document
|
||||
commit, the next run re-applies the green verdict without calling the model
|
||||
(#499); any other change to the tree — a rebase included — gets a full review.
|
||||
|
||||
+8
-3
@@ -359,9 +359,14 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
Файл генерируется `node scripts/reviews-index.mjs` и пересобирается конвейером
|
||||
тем же коммитом, что публикует документ, а также после каждого его ребейза
|
||||
(приведение к dev перед ревью, слияние кандидата — `--commit-if-stale`, коммит
|
||||
класса C); руками не правится. Шаг Validate «индекс ревью совпадает с
|
||||
каталогом» красит push в `dev`, где `INDEX.md` расходится с каталогом (на
|
||||
issue-ветках не судится: их переписывает конвейер). Правка `docs/reviews/`
|
||||
класса C); руками не правится. Конфликт ребейза, в котором **все** пути —
|
||||
`INDEX.md`, отказом не считается (#643): `scripts/rebase-generated.mjs`
|
||||
пересобирает индекс по каталогу на остановке и продолжает ребейз — так делают
|
||||
приведение к dev, слияние кандидата и авторский `rebase-on-dev.mjs`; индекс
|
||||
вместе с любым другим путём — прежний отказ с перечнем файлов. Шаг Validate
|
||||
«индекс ревью совпадает с каталогом» красит push в `dev`, где `INDEX.md`
|
||||
расходится с каталогом (на issue-ветках не судится: их переписывает конвейер).
|
||||
Правка `docs/reviews/`
|
||||
руками — перенос в `legacy/`, удаление — сопровождается
|
||||
`node scripts/reviews-index.mjs` в том же коммите. Прежде чем брать
|
||||
задачу по подсистеме, стоит прочитать её строки в индексе: что находили и чем
|
||||
|
||||
@@ -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`), а тест «индекс свеж» в
|
||||
|
||||
@@ -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. Каждая защита — от тихого
|
||||
// расхождения выжимки с каноном или тихого роста входа.
|
||||
|
||||
Executable
+172
@@ -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
@@ -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 в бандле — не версия этой ветки. Собираем и, если
|
||||
|
||||
@@ -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`, и вывод обрезался посередине.
|
||||
|
||||
@@ -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<<EOF_FILES'/, 'выход conflict/conflicts на месте');
|
||||
// Порядок: ребейз → индекс свеж → push с lease → ожидание ссылки.
|
||||
const order = ['rebase-generated.mjs', '--commit-if-stale --issue="$NUM"', '--force-with-lease="refs/heads/$BRANCH:$before"', 'if [ "$seen" = "$after" ]; then settled=true'];
|
||||
const at = order.map((needle) => 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<<EOF_FILES\n${REVIEWS_INDEX_PATH}\nshared.txt\nEOF_FILES\n`);
|
||||
assert.equal(git(work, 'rev-parse', 'HEAD'), before);
|
||||
assert.equal(git(work, 'status', '--porcelain'), '');
|
||||
} finally { rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('#643 process.yml на настоящем bash: сбой помощника (Node вышел с 1) — ошибка шага, а не conflict', (t) => {
|
||||
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 }); }
|
||||
});
|
||||
@@ -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)', () => {
|
||||
|
||||
Reference in New Issue
Block a user