mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-02 12:49:56 +00:00
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:
@@ -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
@@ -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/`
|
||||
|
||||
@@ -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';
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user