mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-04 13:48:57 +00:00
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:
+5
-1
@@ -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): слияние не превращает
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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], /оставлена: её вершина сдвинулась после слияния/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user