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
+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',