Files
RahulGautamSingh 614b15d22b fix(prune): skip branch pruning when branchPrefix is empty (#43891)
* fix(prune): skip branch pruning when branchPrefix is empty

* remove todo

* debug -> awrn
2026-09-02 20:03:42 +00:00

208 lines
7.2 KiB
TypeScript

import { isNonEmptyStringAndNotWhitespace } from '@sindresorhus/is';
import { GlobalConfig } from '../../../config/global.ts';
import type { RenovateConfig } from '../../../config/types.ts';
import { REPOSITORY_CHANGED } from '../../../constants/error-messages.ts';
import { logger } from '../../../logger/index.ts';
import { ensureComment } from '../../../modules/platform/comment.ts';
import { platform } from '../../../modules/platform/index.ts';
import { scm } from '../../../modules/platform/scm.ts';
import { getBranchList, setUserRepoConfig } from '../../../util/git/index.ts';
import { regEx } from '../../../util/regex.ts';
import { uniqueStrings } from '../../../util/string.ts';
import { isMultiBaseBranch } from '../process/index.ts';
import { getReconfigureBranchName } from '../reconfigure/utils.ts';
async function cleanUpBranches(
config: RenovateConfig,
remainingBranches: string[],
): Promise<void> {
if (!config.pruneStaleBranches) {
logger.debug('Branch/PR pruning is disabled - skipping');
return;
}
// set Git author in case the repository is not initialized yet
setUserRepoConfig(config);
// in multi-base mode the base branch is encoded in the branch name
const multiBase = isMultiBaseBranch(config);
const baseBranchRe = multiBase ? calculateBaseBranchRegex(config) : null;
for (const branchName of remainingBranches) {
try {
let baseBranch: string;
if (multiBase) {
baseBranch =
baseBranchRe?.exec(branchName)?.[1] ?? config.defaultBranch!;
} else {
// single base branch: branch name doesn't encode it, use the configured one
baseBranch = config.baseBranches?.[0] ?? config.defaultBranch!;
}
const pr = await platform.findPr({
branchName,
state: 'open',
targetBranch: baseBranch,
});
const branchIsModified = await scm.isBranchModified(
branchName,
baseBranch,
);
if (pr) {
if (branchIsModified) {
logger.debug(
{ prNo: pr.number, prTitle: pr.title },
'Branch is modified - skipping PR autoclosing',
);
if (GlobalConfig.get('dryRun')) {
logger.info(`DRY-RUN: Would update PR title and ensure comment.`);
} else {
if (!pr.title.endsWith('- abandoned')) {
const newPrTitle = `${pr.title} - abandoned`;
await platform.updatePr({
number: pr.number,
prTitle: newPrTitle,
state: 'open',
});
}
await ensureComment({
number: pr.number,
topic: 'Autoclosing Skipped',
content:
'This PR has been flagged for autoclosing. However, it is being skipped due to the branch being already modified. Please close/delete it manually or report a bug if you think this is in error.',
});
}
} else if (GlobalConfig.get('dryRun')) {
logger.info(
{ prNo: pr.number, prTitle: pr.title },
`DRY-RUN: Would autoclose PR`,
);
} else {
logger.info(
{ branchName, prNo: pr.number, prTitle: pr.title },
'Autoclosing PR',
);
let newPrTitle = pr.title;
if (!pr.title.endsWith('- autoclosed')) {
newPrTitle += ' - autoclosed';
}
await platform.updatePr({
number: pr.number,
prTitle: newPrTitle,
state: 'closed',
});
await scm.deleteBranch(branchName);
}
} else if (branchIsModified) {
logger.debug(
{ branch: branchName },
'Orphan Branch is modified - skipping branch deletion',
);
} else if (GlobalConfig.get('dryRun')) {
logger.info(`DRY-RUN: Would delete orphan branch ${branchName}`);
} else {
logger.info({ branch: branchName }, `Deleting orphan branch`);
await scm.deleteBranch(branchName);
}
} catch (err) /* istanbul ignore next */ {
if (err.message === 'config-validation') {
logger.debug(
'Cannot prune branch due to collision between tags and branch names',
);
} else if (err.message?.includes("bad revision 'origin/")) {
logger.debug(
{ branchName },
'Branch not found on origin when attempting to prune',
);
} else if (err.message !== REPOSITORY_CHANGED) {
logger.warn({ err, branch: branchName }, 'Error pruning branch');
}
}
}
}
/**
* Calculates a {RegExp} to extract the base branch from a branch name if base branch patterns is configured.
* @param config Renovate configuration
*/
function calculateBaseBranchRegex(config: RenovateConfig): RegExp | null {
if (!config.baseBranchPatterns?.length || !config.baseBranches?.length) {
return null;
}
// calculate possible branch prefixes and escape for regex
const branchPrefixes = [config.branchPrefix, config.branchPrefixOld]
.filter(isNonEmptyStringAndNotWhitespace)
.filter(uniqueStrings)
.map((prefix) => RegExp.escape(prefix));
const baseBranches = config.baseBranches.map((branch) =>
RegExp.escape(branch),
);
// create regex to extract base branche from branch name
const baseBranchRe = regEx(
`^(?:${branchPrefixes.join('|')})(${baseBranches.join('|')})-`,
);
return baseBranchRe;
}
export async function pruneStaleBranches(
config: RenovateConfig,
branchList: string[] | null | undefined,
): Promise<void> {
logger.debug('Removing any stale branches');
logger.trace({ config }, `pruneStaleBranches`);
// TODO: types (#22198)
logger.debug(`config.repoIsOnboarded=${config.repoIsOnboarded!}`);
if (!branchList) {
logger.debug('No branchList');
return;
}
if (!config.defaultBranch) {
logger.debug('No defaultBranch set - skipping branch pruning');
return;
}
if (!isNonEmptyStringAndNotWhitespace(config.branchPrefix)) {
// An empty branchPrefix matches every branch in the repo, so Renovate
// cannot reliably tell its own branches apart from unrelated ones. Skip
// pruning to avoid deleting non-Renovate branches as orphans.
logger.warn(
'config.branchPrefix is empty - skipping branch pruning to avoid treating all branches as Renovate-managed',
);
return;
}
// TODO: types (#22198)
let renovateBranches = getBranchList().filter(
(branchName) =>
branchName.startsWith(config.branchPrefix!) &&
branchName !== getReconfigureBranchName(config.branchPrefix!),
);
if (!renovateBranches?.length) {
logger.debug('No renovate branches found');
return;
}
logger.debug(
{
branchList: branchList?.sort(),
renovateBranches: renovateBranches?.sort(),
},
'Branch lists',
);
const lockFileBranch = `${config.branchPrefix}lock-file-maintenance`;
renovateBranches = renovateBranches.filter(
(branch) => branch !== lockFileBranch,
);
const remainingBranches = renovateBranches.filter(
(branch) => !branchList.includes(branch),
);
logger.debug(`remainingBranches=${String(remainingBranches)}`);
if (remainingBranches.length === 0) {
logger.debug('No branches to clean up');
return;
}
await cleanUpBranches(config, remainingBranches);
}