From 19dc61db15fd2656b771d02bb080544373dd7722 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 23:57:01 +0300 Subject: [PATCH] process: the merge deletes the task branch it merged (#702) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd --- PROCESS.md | 6 +++++- scripts/merge-candidate.mjs | 29 ++++++++++++++++++++++++----- scripts/mutation-registry.mjs | 12 ++++++++++++ test/merge-candidate.test.mjs | 35 ++++++++++++++++++++++++++++++++++- 4 files changed, 75 insertions(+), 7 deletions(-) diff --git a/PROCESS.md b/PROCESS.md index 6e5d7a15..a879b453 100644 --- a/PROCESS.md +++ b/PROCESS.md @@ -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): слияние не превращает diff --git a/scripts/merge-candidate.mjs b/scripts/merge-candidate.mjs index 170f81f4..dccec5f0 100755 --- a/scripts/merge-candidate.mjs +++ b/scripts/merge-candidate.mjs @@ -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 }); } diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs index 36ccc45f..5db56988 100644 --- a/scripts/mutation-registry.mjs +++ b/scripts/mutation-registry.mjs @@ -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', diff --git a/test/merge-candidate.test.mjs b/test/merge-candidate.test.mjs index 85e775cb..5b2ba735 100755 --- a/test/merge-candidate.test.mjs +++ b/test/merge-candidate.test.mjs @@ -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], /оставлена: её вершина сдвинулась после слияния/); +});