From 5986332eda915ab6fc08c4cd6e8a71bf8de3332b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 23:20:22 +0300 Subject: [PATCH] process: the rebase merges what two tasks never disagree on (#698) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd --- .gitattributes | 6 +++ PROCESS.md | 7 +++- scripts/merge-candidate.mjs | 9 +++- scripts/mutation-registry.mjs | 36 +++++++++++++++- scripts/rebase-generated.mjs | 25 ++++++++--- test/rebase-generated.test.mjs | 77 ++++++++++++++++++++++++++++++++-- 6 files changed, 149 insertions(+), 11 deletions(-) diff --git a/.gitattributes b/.gitattributes index 5f8934b1..2e37b6c3 100644 --- a/.gitattributes +++ b/.gitattributes @@ -12,3 +12,9 @@ *.zip binary *.woff binary *.woff2 binary + +# #698: записи двух задач в `## Unreleased` друг другу не противоречат — при +# слиянии и ребейзе git объединяет обе стороны сам (встроенный драйвер union), +# и параллельные задачи не возвращаются в S6 из-за соседней строки ченджлога. +docs/CHANGELOG.md merge=union +docs/CHANGELOG.ru.md merge=union diff --git a/PROCESS.md b/PROCESS.md index 855863ef..12c401d3 100644 --- a/PROCESS.md +++ b/PROCESS.md @@ -389,7 +389,12 @@ dev, ни при публикации документа код-ревью: ин `INDEX.md`, отказом не считается (#643): `scripts/rebase-generated.mjs` пересобирает индекс по каталогу на остановке и продолжает ребейз — так делают приведение к dev, слияние кандидата и авторский `rebase-on-dev.mjs`; индекс -вместе с любым другим путём — прежний отказ с перечнем файлов. Шаг Validate +вместе с любым другим путём — прежний отказ с перечнем файлов. Ещё два общих +файла не конфликтуют по смыслу (#698): записи ченджлогов в `## Unreleased` +объединяет встроенный драйвер `merge=union` (`.gitattributes`), а на конфликте в +`scripts/monolith-baseline.json` ребейз берёт сторону `dev` — число для +объединённого дерева судит тест полосы на Validate (#699). Ни то, ни другое не +входит в patch-id кандидата слияния: вердикт к работе задачи остаётся в силе. Шаг Validate «индекс ревью совпадает с каталогом» красит push в `dev`, где `INDEX.md` расходится с каталогом (на issue-ветках не судится: их переписывает конвейер). Правка `docs/reviews/` diff --git a/scripts/merge-candidate.mjs b/scripts/merge-candidate.mjs index 36ba4506..170f81f4 100755 --- a/scripts/merge-candidate.mjs +++ b/scripts/merge-candidate.mjs @@ -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'; }, diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs index aabe01ce..36ccc45f 100644 --- a/scripts/mutation-registry.mjs +++ b/scripts/mutation-registry.mjs @@ -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', diff --git a/scripts/rebase-generated.mjs b/scripts/rebase-generated.mjs index 0aa16622..06077e4a 100755 --- a/scripts/rebase-generated.mjs +++ b/scripts/rebase-generated.mjs @@ -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(); diff --git a/test/rebase-generated.test.mjs b/test/rebase-generated.test.mjs index 47cb230a..f9690567 100644 --- a/test/rebase-generated.test.mjs +++ b/test/rebase-generated.test.mjs @@ -6,7 +6,8 @@ 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 { REVIEWS_INDEX_PATH, UPSTREAM_WINS, planStop, rebaseRegenerating } from '../scripts/rebase-generated.mjs'; +import { PATCH_ID_EXCLUDES } from '../scripts/merge-candidate.mjs'; import { buildIndex } from '../scripts/reviews-index.mjs'; // #643: doc-коммит ветки задачи конфликтует с dev только в генерируемом @@ -106,7 +107,7 @@ function scenario({ shared = false, tools = false } = {}) { test('#643 planStop: разрешается только набор, где ВСЕ конфликты — индекс или объявленные вызывающим', () => { assert.deepEqual(planStop([REVIEWS_INDEX_PATH, '', ` ${REVIEWS_INDEX_PATH}`]), - { action: 'resolve', index: true, extra: [], conflicts: [REVIEWS_INDEX_PATH] }); + { action: 'resolve', index: true, upstream: [], extra: [], conflicts: [REVIEWS_INDEX_PATH] }); const mixed = planStop([REVIEWS_INDEX_PATH, 'src/x.ts']); assert.equal(mixed.action, 'abort'); assert.equal(mixed.reason, 'manual'); @@ -116,7 +117,7 @@ test('#643 planStop: разрешается только набор, где ВС 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] }); + assert.deepEqual(bundle, { action: 'resolve', index: true, upstream: [], extra: ['dist/a.js'], conflicts: ['dist/a.js', REVIEWS_INDEX_PATH] }); }); test('#643 AC1: конфликт только в INDEX.md — ребейз проходит, индекс равен пересборке каталога', () => { @@ -293,3 +294,73 @@ test('#643 process.yml на настоящем bash: сбой помощника assert.match(r.stdout, /::error::помощник ребейза упал/); } finally { rmSync(root, { recursive: true, force: true }); } }); + +// ---------- #698: ченджлог объединяется, база метрик берётся из dev ---------- + +function sharedFilesScenario({ baselineConflict = false, alsoCode = false } = {}) { + const root = mkdtempSync(join(tmpdir(), 'hp-rebase-698-')); + const work = join(root, 'work'); + mkdirSync(join(work, 'docs'), { recursive: true }); + mkdirSync(join(work, 'scripts'), { recursive: true }); + git(root, 'init', '-q', '-b', 'dev', work); + copyFileSync(fileURLToPath(new URL('../.gitattributes', import.meta.url)), join(work, '.gitattributes')); + const log = (lines) => writeFileSync(join(work, 'docs', 'CHANGELOG.md'), `# Changelog\n\n## Unreleased\n\n${lines.join('\n')}\n\n## 1.0.0\n\n- first\n`); + const baseline = (hostRefs) => writeFileSync(join(work, 'scripts', 'monolith-baseline.json'), `${JSON.stringify({ delegates: 1, hostRefs }, null, 2)}\n`); + log([]); baseline(100); + writeFileSync(join(work, 'a.mjs'), 'export const a = 1;\n'); + commitAll(work, 'base'); + git(work, 'checkout', '-q', '-b', 'issue/9-x'); + log(['- task nine']); + if (baselineConflict) baseline(95); + if (alsoCode) writeFileSync(join(work, 'a.mjs'), 'export const a = 9;\n'); + commitAll(work, 'task'); + git(work, 'checkout', '-q', 'dev'); + log(['- task eight']); + if (baselineConflict) baseline(103); + if (alsoCode) writeFileSync(join(work, 'a.mjs'), 'export const a = 8;\n'); + commitAll(work, 'neighbour'); + git(work, 'checkout', '-q', 'issue/9-x'); + return { root, work }; +} + +test('#698: записи ченджлога двух задач объединяются при ребейзе, конфликта нет', (t) => { + const { root, work } = sharedFilesScenario(); + t.after(() => rmSync(root, { recursive: true, force: true })); + const result = rebaseRegenerating({ onto: 'dev', cwd: work, env: ENV }); + assert.equal(result.ok, true, JSON.stringify(result)); + assert.equal(result.stops, 0, 'union — это не остановка ребейза'); + const text = readFileSync(join(work, 'docs', 'CHANGELOG.md'), 'utf8'); + assert.match(text, /- task eight\n- task nine\n/, 'обе записи в Unreleased, сторона dev первой'); + assert.doesNotMatch(text, /^(<<<<<<<|=======|>>>>>>>)/m); +}); + +test('#698: конфликт в базе метрик монолита решается в пользу dev', (t) => { + const { root, work } = sharedFilesScenario({ baselineConflict: true }); + t.after(() => rmSync(root, { recursive: true, force: true })); + const result = rebaseRegenerating({ onto: 'dev', cwd: work, env: ENV }); + assert.equal(result.ok, true, JSON.stringify(result)); + assert.deepEqual(result.resolved, ['scripts/monolith-baseline.json ← dev']); + assert.equal(JSON.parse(readFileSync(join(work, 'scripts', 'monolith-baseline.json'), 'utf8')).hostRefs, 103); + assert.match(readFileSync(join(work, 'docs', 'CHANGELOG.md'), 'utf8'), /- task nine/); + assert.deepEqual(UPSTREAM_WINS, ['scripts/monolith-baseline.json']); +}); + +test('#698: база метрик вместе с конфликтом в коде — прежний отказ с перечнем', (t) => { + const { root, work } = sharedFilesScenario({ baselineConflict: true, alsoCode: true }); + t.after(() => rmSync(root, { recursive: true, force: true })); + const before = git(work, 'rev-parse', 'HEAD'); + const result = rebaseRegenerating({ onto: 'dev', cwd: work, env: ENV }); + assert.equal(result.ok, false); + assert.deepEqual(result.manual, ['a.mjs']); + assert.deepEqual(result.conflicts, ['a.mjs', 'scripts/monolith-baseline.json']); + assert.equal(git(work, 'rev-parse', 'HEAD'), before, 'ребейз отменён, HEAD как был'); +}); + +test('#698: patch-id кандидата не видит того, что ребейз сливает сам', () => { + for (const path of ['docs/reviews', 'docs/CHANGELOG.md', 'docs/CHANGELOG.ru.md', 'scripts/monolith-baseline.json']) { + assert.ok(PATCH_ID_EXCLUDES.includes(`:!${path}`), path); + } + const attrs = readFileSync(fileURLToPath(new URL('../.gitattributes', import.meta.url)), 'utf8'); + assert.match(attrs, /^docs\/CHANGELOG\.md merge=union$/m); + assert.match(attrs, /^docs\/CHANGELOG\.ru\.md merge=union$/m); +});