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
+6
View File
@@ -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
+6 -1
View File
@@ -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/`
+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();
+74 -3
View File
@@ -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);
});