process: the rebase merges what two tasks never disagree on (#698)

14 of 48 returns in #600–#691 were rebase or merge conflicts on shared
files where the two edits do not contradict each other.

- .gitattributes: docs/CHANGELOG.md and docs/CHANGELOG.ru.md use the
  built-in merge=union driver — both tasks' lines in ## Unreleased survive
  a rebase, a merge and git merge-tree (#696's clean-merge test) without a
  stop.
- rebase-generated.mjs: UPSTREAM_WINS — on a conflict in
  scripts/monolith-baseline.json the rebase takes dev's side; the band test
  on the candidate's Validate judges the merged tree (#699). Any other
  conflicting path aborts exactly as before, with the full list.
- merge-candidate.mjs: the candidate's patch-id excludes the changelogs and
  the monolith baseline next to docs/reviews, so a neighbour's line next to
  the task entry does not re-send a green task to review.
- screenshots.json needs nothing: after #697 task branches do not commit it.

Canon: PROCESS.md, the rebase paragraph of the review index (#643).

Issue: #698
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
This commit is contained in:
Claude
2026-09-28 21:01:38 +00:00
committed by claude[bot]
parent 13054e246e
commit 5986332eda
6 changed files with 149 additions and 11 deletions
+8 -1
View File
@@ -34,6 +34,10 @@ import { CONVEYOR_IDENTITY } from './reviews-index.mjs';
const REVIEWS_INDEX_SCRIPT = fileURLToPath(new URL('./reviews-index.mjs', import.meta.url));
export const MAX_ATTEMPTS = 3;
/** Пути вне patch-id кандидата (#698): документы ревью и то, что ребейз сливает сам. */
export const PATCH_ID_EXCLUDES = Object.freeze([
':!docs/reviews', ':!docs/CHANGELOG.md', ':!docs/CHANGELOG.ru.md', ':!scripts/monolith-baseline.json',
]);
export const VALIDATE_APPEAR_MS = 3 * 60 * 1000;
export const VALIDATE_TOTAL_MS = 45 * 60 * 1000;
@@ -145,8 +149,11 @@ export function realOps({
// Документы ревью — не часть патча (#516): кандидат несёт свой
// CODE-REVIEW-N-rK.md, материал — нет, и без pathspec их patch-id
// расходились на каждом сдвиге dev; `reviewedFresh` судит так же.
// #698: вердикт судит работу задачи. Ченджлоги объединяет `merge=union`,
// базу метрик монолита ребейз берёт из dev: строки соседей рядом с записью
// задачи меняют контекст диффа, но не то, что читал ревьюер.
patchId: (from, to) => {
const diff = must(git('diff', '--full-index', from, to, '--', '.', ':!docs/reviews'), 'diff');
const diff = must(git('diff', '--full-index', from, to, '--', '.', ...PATCH_ID_EXCLUDES), 'diff');
const r = spawnSync('git', ['patch-id', '--stable'], { input: diff, encoding: 'utf8' });
return (r.stdout || '').trim().split(' ')[0] || 'empty';
},
+35 -1
View File
@@ -12129,10 +12129,44 @@ const MUTANT_DEFINITIONS = [
+ 'that counts docs/reviews sends every green candidate back to review whenever dev moved (#516)',
patches: [{
file: 'scripts/merge-candidate.mjs',
find: " const diff = must(git('diff', '--full-index', from, to, '--', '.', ':!docs/reviews'), 'diff');",
find: " const diff = must(git('diff', '--full-index', from, to, '--', '.', ...PATCH_ID_EXCLUDES), 'diff');",
replace: " const diff = must(git('diff', '--full-index', from, to), 'diff'); // mutant: review docs count",
}],
},
// #698: ребейз сам сливает то, в чём две задачи не противоречат друг другу.
{
id: 'changelog-union-driver-dropped',
guard: 'node --test --test-name-pattern="#698: записи ченджлога" test/rebase-generated.test.mjs',
because: '#698: two tasks adding lines to ## Unreleased must not send the second one back to S6; '
+ 'the built-in union driver merges both sides',
patches: [{
file: '.gitattributes',
find: 'docs/CHANGELOG.md merge=union\n',
replace: '',
}],
},
{
id: 'monolith-baseline-conflict-is-manual-again',
guard: 'node --test --test-name-pattern="#698: конфликт в базе" test/rebase-generated.test.mjs',
because: '#698: the monolith metrics baseline is a snapshot, not a decision; on a rebase conflict '
+ 'dev wins and the band test on the candidate judges the merged tree',
patches: [{
file: 'scripts/rebase-generated.mjs',
find: "export const UPSTREAM_WINS = Object.freeze(['scripts/monolith-baseline.json']);",
replace: 'export const UPSTREAM_WINS = Object.freeze([]); // mutant: every baseline conflict is manual',
}],
},
{
id: 'merge-patch-id-sees-changelog',
guard: 'node --test --test-name-pattern="#698: patch-id" test/rebase-generated.test.mjs',
because: '#698: a neighbour line next to the task entry changes the diff context, not what the '
+ 'reviewer read; counting it re-sends a green task to review',
patches: [{
file: 'scripts/merge-candidate.mjs',
find: " ':!docs/reviews', ':!docs/CHANGELOG.md', ':!docs/CHANGELOG.ru.md', ':!scripts/monolith-baseline.json',",
replace: " ':!docs/reviews', ':!scripts/monolith-baseline.json',",
}],
},
{
id: 'merge-trusts-success-without-proof',
guard: 'node --test test/merge-candidate.test.mjs',
+20 -5
View File
@@ -10,8 +10,8 @@
// и не «их», а пересборка по дереву, в котором остановился ребейз.
//
// Правило одно: остановку разрешает только набор конфликтов, в котором ВСЕ
// пути — индекс (или пути, которые вызывающий объявил своими: бандл в
// `rebase-on-dev.mjs`). Хоть один другой путь — `git rebase --abort` и
// пути — индекс, данные, где права сторона dev (`UPSTREAM_WINS`, #698), или
// пути, которые вызывающий объявил своими (бандл в `rebase-on-dev.mjs`). Хоть один другой путь — `git rebase --abort` и
// перечень ВСЕХ конфликтующих файлов, индекс в нём тоже: автор видит полную
// картину, дерево и HEAD как были.
//
@@ -32,6 +32,14 @@ import { fileURLToPath } from 'node:url';
import { isMainModule } from './spawn-portable.mjs';
export const REVIEWS_INDEX_PATH = 'docs/reviews/INDEX.md';
/**
* #698: данные, в которых на конфликте права сторона `dev`. База метрик
* монолита — снимок чисел, а не решение задачи: правильное значение для
* объединённого дерева не «наше» и не «их», и судит его тест полосы на
* Validate кандидата (#699), а не автор руками. Ченджлоги сюда не входят —
* их объединяет `merge=union` в `.gitattributes`, и конфликта не бывает.
*/
export const UPSTREAM_WINS = Object.freeze(['scripts/monolith-baseline.json']);
/** Скрипт индекса — по абсолютному пути: ребейз идёт и из чужого cwd (worktree кандидата). */
export const REVIEWS_INDEX_SCRIPT = fileURLToPath(new URL('./reviews-index.mjs', import.meta.url));
/** Предохранитель от зацикливания: коммитов в ветке задачи единицы, не тысячи. */
@@ -46,18 +54,19 @@ const uniquePaths = (paths) => [...new Set(paths.map((p) => String(p).trim()).fi
*
* @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[] }
* @returns {{ action: 'resolve', index: boolean, upstream: string[], 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));
const manual = conflicts.filter((path) => path !== REVIEWS_INDEX_PATH && !UPSTREAM_WINS.includes(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),
upstream: conflicts.filter((path) => UPSTREAM_WINS.includes(path)),
extra: conflicts.filter((path) => path !== REVIEWS_INDEX_PATH && !UPSTREAM_WINS.includes(path)),
conflicts,
};
}
@@ -127,6 +136,12 @@ export function rebaseRegenerating({
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)}`);
// На ребейзе `--ours` — сторона, НА которую ребейзят, то есть dev.
for (const path of plan.upstream) {
git(['checkout', '--ours', '--', path]);
git(['add', '--', path]);
resolved.push(`${path} ← dev`);
}
if (plan.index) {
// Не --ours и не --theirs: ни одна сторона не знает документов другой.
rebuildIndex();