process: the merge deletes the task branch it merged (#702)

370 merged issue/* branches sat on origin; the branch list stopped meaning
anything and an agent looking a branch up by number could take a stale one.

- merge-candidate.mjs: after a successful push to dev the task branch is
  deleted with --force-with-lease on the tip the merge saw last — the
  candidate published into the branch, or the material on fast-forward
  (the index commit lives only in dev). A commit that landed after the
  merge keeps the branch, and the merge comment says so; a failed delete
  never undoes the merge. Failed, stale and conflicting merges keep it.
- The one-time cleanup of the already merged branches is not in this
  commit: the list goes to the owner first.

Canon: PROCESS.md §10.4 (exact-candidate merge).

Issue: #702
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:18:49 +00:00
committed by claude[bot]
parent c66884b7f5
commit 19dc61db15
4 changed files with 75 additions and 7 deletions
+5 -1
View File
@@ -1247,7 +1247,11 @@ npm ci, Python и Chromium, оставаясь исполненной job: до
push-прогон, а dispatch шлёт, только если его нет за три минуты (#696). Отклонённый lease — `dev` двинулся снова
— новая попытка; после третьей — `S6-in-progress` с комментарием;
- красный Validate на кандидате или прогон, не появившийся за три минуты, —
`S6-in-progress` с ссылкой; `S8-merged` ставится только после push.
`S6-in-progress` с ссылкой; `S8-merged` ставится только после push;
- после push ветка задачи удаляется с lease на влитую вершину (#702): коммит,
прилетевший после слияния, её сохраняет, и комментарий об этом говорит.
Влитая ветка никому не нужна, а агент, ищущий ветку по номеру, иначе может
взять устаревшую.
Проверка кандидата — обычный Validate ветки: лёгкий набор плюс диффозависимые
гейты. Тяжёлые гейты остаются за кандидатом релиза (#479): слияние не превращает
+24 -5
View File
@@ -101,7 +101,9 @@ export function commentFor(action, ctx) {
case 'fast-forward':
return `материал \`${short(ctx.material)}\` · dev@\`${short(ctx.devNow)}\` → кандидат \`${short(ctx.candidate)}\``
+ (action === 'push' ? ` · Validate ${ctx.runUrl} зелёный` : ' · dev не двигался')
+ ' · слито';
+ ' · слито'
+ (ctx.branchDeleted === true ? ` · ветка \`${ctx.branch}\` удалена` : '')
+ (ctx.branchDeleted === false ? ` · ветка \`${ctx.branch}\` оставлена: её вершина сдвинулась после слияния` : '');
default:
return '';
}
@@ -186,6 +188,14 @@ export function realOps({
must(exec(process.execPath, [REVIEWS_INDEX_SCRIPT, '--dir=docs/reviews', '--commit-if-stale', `--issue=${issue}`]), 'reviews-index --commit-if-stale');
return must(git('rev-parse', 'HEAD'), 'rev-parse HEAD');
},
// #702: ветка задачи удаляется после слияния — только если её вершина всё
// ещё та, что влита (lease): коммит, прилетевший после, не теряется.
deleteBranch: (ref, expected) => {
const r = git('push', '-q', `--force-with-lease=refs/heads/${ref}:${expected}`, pushUrl, `:refs/heads/${ref}`);
if (r.status === 0) return true;
if (/stale info|rejected|fetch first|lease/i.test(r.stderr)) return false;
throw new Error(`git push :${ref}: ${r.stderr}`);
},
pushWithLease: (sha, ref, expected) => {
const r = git('push', '-q', `--force-with-lease=refs/heads/${ref}:${expected}`, pushUrl, `${sha}:refs/heads/${ref}`);
if (r.status === 0) return true;
@@ -250,9 +260,18 @@ export async function mergeCandidate({ branch, material, issue, ops, maxAttempts
&& ops.diffNames(material, actual, ['.', ':!docs/reviews']).length === 0);
const ctx = { branch, material, actual, issue };
const finish = (decision, extra = {}) => {
const body = commentFor(decision.action, { ...ctx, ...extra, attempt: extra.attempt });
if (body) ops.comment(issue, body);
const merged = decision.action === 'push' || decision.action === 'fast-forward';
// #702: влитая ветка больше не нужна — 368 таких висели на origin, и агент,
// искавший ветку по номеру, мог взять устаревшую. `branchTip` — вершина,
// которую слияние видело последней: кандидат, опубликованный в ветку, либо
// материал при fast-forward. Сбой удаления слияние не отменяет.
let branchDeleted = null;
if (merged && extra.branchTip) {
try { branchDeleted = ops.deleteBranch(branch, extra.branchTip); }
catch (error) { ops.log(`ветка ${branch} не удалена: ${error.message}`); }
}
const body = commentFor(decision.action, { ...ctx, ...extra, attempt: extra.attempt, branchDeleted });
if (body) ops.comment(issue, body);
ops.log(`решение: ${decision.action} → ${decision.to || '(метка по вердикту)'}`);
return { merged, to: decision.to, action: decision.action, candidate: extra.candidate || actual };
};
@@ -272,7 +291,7 @@ export async function mergeCandidate({ branch, material, issue, ops, maxAttempts
const pushed = ops.pushWithLease(target, 'dev', devNow);
const decision = decideMerge({ fresh: true, devMoved: false, leaseRejected: !pushed });
if (decision.action === 'retry') continue;
return finish(decision, { candidate: target, devNow });
return finish(decision, { candidate: target, devNow, branchTip: tip });
}
const candidate = ops.rebaseOnto(tip, 'origin/dev');
@@ -317,7 +336,7 @@ export async function mergeCandidate({ branch, material, issue, ops, maxAttempts
const pushed = ops.pushWithLease(candidate, 'dev', devNow);
decision = decideMerge({ fresh: true, devMoved: true, patchIdEqual: true, validate: result, leaseRejected: !pushed, attempt, maxAttempts });
if (decision.action === 'retry') { ops.log('dev двинулся снова — ещё попытка'); continue; }
return finish(decision, { candidate, devNow, runUrl: url, attempt });
return finish(decision, { candidate, devNow, runUrl: url, attempt, branchTip: candidate });
}
return finish(decideMerge({ fresh: true, devMoved: true, patchIdEqual: true, validate: 'green', leaseRejected: true, attempt: maxAttempts, maxAttempts }), { candidate: tip, attempt: maxAttempts });
}
+12
View File
@@ -5509,6 +5509,18 @@ const MUTANT_DEFINITIONS = [
replace: " const pushed = ops.pushWithLease(candidate, 'dev', devNow) || true; // mutant: lease never rejected\n decision = decideMerge(",
}],
},
// #702: влитая ветка задачи удаляется слиянием.
{
id: 'merged-task-branch-kept',
guard: 'node --test --test-name-pattern="#702" test/merge-candidate.test.mjs',
because: '#702: 368 merged issue/* branches piled up on origin; an agent looking a branch up by '
+ 'number could take a stale one — the merge deletes the branch it merged',
patches: [{
file: 'scripts/merge-candidate.mjs',
find: ' if (merged && extra.branchTip) {',
replace: ' if (false && merged && extra.branchTip) { // mutant: branch kept',
}],
},
{
id: 'nightly-does-not-wait',
guard: 'node --test --test-name-pattern="nightly ждёт запущенный Validate" test/nightly-workflow.test.mjs',
+34 -1
View File
@@ -82,7 +82,7 @@ test('каждый исход, меняющий метку, объясняетс
* dev по порядку (следующая после каждого отклонённого lease), ответы
* Validate — по порядку кандидатов.
*/
function fakeOps({ base = 'dev0', devTips = ['dev0'], validate = [], leaseRejects = 0, patchIds = {}, branchTip, material, conflictOnce = false, indexStale = false }) {
function fakeOps({ base = 'dev0', devTips = ['dev0'], validate = [], leaseRejects = 0, patchIds = {}, branchTip, material, conflictOnce = false, indexStale = false, deleteOk = true }) {
const calls = [];
let devIndex = 0;
let validateIndex = 0;
@@ -123,6 +123,7 @@ function fakeOps({ base = 'dev0', devTips = ['dev0'], validate = [], leaseReject
return { result, url: `https://run/${sha}` };
},
comment: (issue, body) => { calls.push(['comment', body.split('\n')[0]]); },
deleteBranch: (ref, expected) => { calls.push(['delete', ref, expected]); return deleteOk; },
log: () => {},
};
}
@@ -642,3 +643,35 @@ test('#696: трек ask по-прежнему диспатчит Validate с м
['dispatch', 'issue/1-x'], ['validate', 'cand-mat-on-dev1', 'workflow_dispatch'],
]);
});
test('#702: после fast-forward ветка задачи удаляется с lease на влитую вершину', async () => {
const ops = fakeOps({ devTips: ['dev0'], branchTip: 'mat', material: 'mat', indexStale: true });
const r = await mergeCandidate({ branch: 'issue/1-x', material: 'mat', issue: 1, ops });
assert.equal(r.merged, true);
assert.deepEqual(ops.calls.filter((c) => c[0] === 'delete'), [['delete', 'issue/1-x', 'mat']],
'вершина ветки — материал: индекс-коммит живёт только в dev');
assert.match(ops.calls.find((c) => c[0] === 'comment')[1], /ветка `issue\/1-x` удалена/);
});
test('#702: после слияния кандидата удаляется ветка с его вершиной; неудачное слияние ветку не трогает', async () => {
const ops = fakeOps({ base: 'dev0', devTips: ['dev1'], branchTip: 'mat', material: 'mat' });
const r = await mergeCandidate({ branch: 'issue/1-x', material: 'mat', issue: 1, ops });
assert.equal(r.action, 'push');
assert.deepEqual(ops.calls.filter((c) => c[0] === 'delete'), [['delete', 'issue/1-x', 'cand-mat-on-dev1']]);
const red = fakeOps({ base: 'dev0', devTips: ['dev1'], branchTip: 'mat', material: 'mat', validate: ['failed'] });
await mergeCandidate({ branch: 'issue/1-x', material: 'mat', issue: 1, ops: red });
assert.deepEqual(red.calls.filter((c) => c[0] === 'delete'), [], 'красный Validate — ветка остаётся автору');
const stale = fakeOps({ devTips: ['dev0'], branchTip: 'other', material: 'mat' });
stale.revParse = (ref) => (ref === 'origin/dev' ? 'dev0' : ref.startsWith('origin/issue') ? 'other' : ref.endsWith('^') ? 'foreign' : ref);
const rejected = await mergeCandidate({ branch: 'issue/1-x', material: 'mat', issue: 1, ops: stale });
assert.equal(rejected.action, 'reject-stale');
assert.deepEqual(stale.calls.filter((c) => c[0] === 'delete'), [], '#312 — ветку не трогаем');
});
test('#702: сдвинутая вершина — ветка остаётся, слияние в силе', async () => {
const ops = fakeOps({ devTips: ['dev0'], branchTip: 'mat', material: 'mat', deleteOk: false });
const r = await mergeCandidate({ branch: 'issue/1-x', material: 'mat', issue: 1, ops });
assert.equal(r.merged, true);
assert.match(ops.calls.find((c) => c[0] === 'comment')[1], /оставлена: её вершина сдвинулась после слияния/);
});