fix: clamp an issue-branch gate range to the branch's own commits

After the mandatory rebase of a published issue branch the pre-push hook
still passes remote_old..local_new, and once the old tip is no longer an
ancestor that range drags in the whole advanced dev history: on #117 it
meant 84 foreign commits and 20 false rule-8 rejections over already
closed issues, leaving --no-verify as the only exit.

The clamp lives in the gate rather than the hook: .githooks/pre-push
carries an executable bit that MCP publication strips (the commit-msg
precedent), so editing it needs an owner-side commit. When the target is
an issue branch and the declared base is not an ancestor of the head, the
base becomes the merge-base with origin/dev. Fast-forward pushes keep
their exact range, every own commit is still judged, and a real violation
in a post-rebase commit still blocks — covered by a scenario test that
goes red without the wiring.

Issue: #190
User-Visible: no
This commit is contained in:
Matysh
2026-08-19 09:48:39 +03:00
parent 1c3404fdd0
commit 2f0dc44f27
2 changed files with 137 additions and 0 deletions
+38
View File
@@ -405,6 +405,33 @@ function ghRunner(nwo, bin) {
};
}
// Диапазон issue-ветки приводится к её собственным коммитам (#190).
//
// pre-push передаёт remote_old..local_new. Для fast-forward это ровно новые
// коммиты, но после обязательного ребейза issue-ветки (возврат из конфликтного
// слияния, §10.4) remote_old перестаёт быть предком local_new, и git log
// показывает всё, что недостижимо из старой вершины, — включая ушедшую вперёд
// историю dev. На #117 это дало 84 чужих коммита и 20 ложных отказов п.8 по
// уже закрытым issue, а единственным выходом оставался `--no-verify`.
//
// Правится здесь, а не в самом хуке: `.githooks/pre-push` несёт бит исполнения,
// который публикация через MCP сбрасывает (прецедент commit-msg), — правка хука
// потребовала бы локального коммита владельца. Заодно это второй страховочный
// слой для любого вызывающего с широким диапазоном, включая CI.
//
// Fail-closed сохраняется: собственные коммиты ветки относительно origin/dev
// проверяются все до одного, сужается только чужая история.
export function clampIssueBranchRange(range, { targetRef = '', isAncestor, mergeBaseWithDev } = {}) {
if (!/^(?:refs\/heads\/)?issue\/\d+-/.test(targetRef ?? '')) return range;
const m = /^([^.\s]+)\.\.([^.\s]+)$/.exec(range ?? '');
if (!m) return range;
const [, base, head] = m;
if (isAncestor(base, head)) return range;
const mergeBase = mergeBaseWithDev(head);
if (!mergeBase) return range;
return `${mergeBase}..${head}`;
}
function main(argv) {
const flag = (name) => argv.includes(`--${name}`);
const value = (name, dflt = null) => {
@@ -433,6 +460,17 @@ function main(argv) {
{ encoding: 'utf8' }).status === 0;
range = hasDev ? 'origin/dev..HEAD' : 'HEAD~20..HEAD';
}
range = clampIssueBranchRange(range, {
targetRef,
isAncestor: (base, head) => spawnSync(
'git', ['-C', repo, 'merge-base', '--is-ancestor', base, head], { encoding: 'utf8' },
).status === 0,
mergeBaseWithDev: (head) => {
const r = spawnSync('git', ['-C', repo, 'merge-base', head, 'refs/remotes/origin/dev'],
{ encoding: 'utf8' });
return r.status === 0 ? r.stdout.trim() : null;
},
});
const filesOf = (sha) =>
git(['show', '--name-only', '--pretty=format:', sha], repo)