mirror of
https://github.com/renovatebot/renovate.git
synced 2025-01-15 09:06:25 +00:00
901 lines
26 KiB
TypeScript
901 lines
26 KiB
TypeScript
import URL from 'url';
|
|
import fs from 'fs-extra';
|
|
import Git, {
|
|
DiffResult as DiffResult_,
|
|
Options,
|
|
ResetMode,
|
|
SimpleGit,
|
|
StatusResult as StatusResult_,
|
|
TaskOptions,
|
|
} from 'simple-git';
|
|
import { join } from 'upath';
|
|
import { getGlobalConfig } from '../../config/global';
|
|
import type { RenovateConfig } from '../../config/types';
|
|
import {
|
|
CONFIG_VALIDATION,
|
|
REPOSITORY_CHANGED,
|
|
REPOSITORY_DISABLED,
|
|
REPOSITORY_EMPTY,
|
|
SYSTEM_INSUFFICIENT_DISK_SPACE,
|
|
TEMPORARY_ERROR,
|
|
} from '../../constants/error-messages';
|
|
import { logger } from '../../logger';
|
|
import { ExternalHostError } from '../../types/errors/external-host-error';
|
|
import { GitOptions, GitProtocol } from '../../types/git';
|
|
import { Limit, incLimitedValue } from '../../workers/global/limits';
|
|
import { parseGitAuthor } from './author';
|
|
import { GitNoVerifyOption, getNoVerify } from './config';
|
|
import { configSigningKey, writePrivateKey } from './private-key';
|
|
|
|
export { GitNoVerifyOption, setNoVerify } from './config';
|
|
export { setPrivateKey } from './private-key';
|
|
|
|
declare module 'fs-extra' {
|
|
export function exists(pathLike: string): Promise<boolean>;
|
|
}
|
|
|
|
export type StatusResult = StatusResult_;
|
|
|
|
export type DiffResult = DiffResult_;
|
|
|
|
export type CommitSha = string;
|
|
|
|
interface StorageConfig {
|
|
currentBranch?: string;
|
|
url: string;
|
|
extraCloneOpts?: GitOptions;
|
|
cloneSubmodules?: boolean;
|
|
}
|
|
|
|
interface LocalConfig extends StorageConfig {
|
|
additionalBranches: string[];
|
|
currentBranch: string;
|
|
currentBranchSha: string;
|
|
branchCommits: Record<string, CommitSha>;
|
|
branchIsModified: Record<string, boolean>;
|
|
branchPrefix: string;
|
|
ignoredAuthors: string[];
|
|
gitAuthorName?: string;
|
|
gitAuthorEmail?: string;
|
|
}
|
|
|
|
// istanbul ignore next
|
|
function checkForPlatformFailure(err: Error): void {
|
|
if (process.env.NODE_ENV === 'test') {
|
|
return;
|
|
}
|
|
const externalHostFailureStrings = [
|
|
'remote: Invalid username or password',
|
|
'gnutls_handshake() failed',
|
|
'The requested URL returned error: 5',
|
|
'The remote end hung up unexpectedly',
|
|
'access denied or repository not exported',
|
|
'Could not write new index file',
|
|
'Failed to connect to',
|
|
'Connection timed out',
|
|
'malformed object name',
|
|
'Could not resolve host',
|
|
'early EOF',
|
|
'fatal: bad config', // .gitmodules problem
|
|
];
|
|
for (const errorStr of externalHostFailureStrings) {
|
|
if (err.message.includes(errorStr)) {
|
|
logger.debug({ err }, 'Converting git error to ExternalHostError');
|
|
throw new ExternalHostError(err, 'git');
|
|
}
|
|
}
|
|
|
|
const configErrorStrings = [
|
|
{
|
|
error: 'GitLab: Branch name does not follow the pattern',
|
|
message:
|
|
"Cannot push because branch name does not follow project's push rules",
|
|
},
|
|
{
|
|
error: 'GitLab: Commit message does not follow the pattern',
|
|
message:
|
|
"Cannot push because commit message does not follow project's push rules",
|
|
},
|
|
{
|
|
error: ' is not a member of team',
|
|
message:
|
|
'The `Restrict commits to existing GitLab users` rule is blocking Renovate push. Check the Renovate `gitAuthor` setting',
|
|
},
|
|
{
|
|
error: 'TF401027:',
|
|
message:
|
|
'You need the Git `GenericContribute` permission to perform this action',
|
|
},
|
|
];
|
|
for (const { error, message } of configErrorStrings) {
|
|
if (err.message.includes(error)) {
|
|
logger.debug({ err }, 'Converting git error to CONFIG_VALIDATION error');
|
|
const res = new Error(CONFIG_VALIDATION);
|
|
res.validationError = message;
|
|
res.validationMessage = err.message;
|
|
throw res;
|
|
}
|
|
}
|
|
}
|
|
|
|
function localName(branchName: string): string {
|
|
return branchName.replace(/^origin\//, '');
|
|
}
|
|
|
|
async function isDirectory(dir: string): Promise<boolean> {
|
|
try {
|
|
return (await fs.stat(dir)).isDirectory();
|
|
} catch (err) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function getDefaultBranch(git: SimpleGit): Promise<string> {
|
|
// see https://stackoverflow.com/a/44750379/1438522
|
|
try {
|
|
const res = await git.raw(['symbolic-ref', 'refs/remotes/origin/HEAD']);
|
|
return res.replace('refs/remotes/origin/', '').trim();
|
|
} catch (err) /* istanbul ignore next */ {
|
|
checkForPlatformFailure(err);
|
|
if (
|
|
err.message.startsWith(
|
|
'fatal: ref refs/remotes/origin/HEAD is not a symbolic ref'
|
|
)
|
|
) {
|
|
throw new Error(REPOSITORY_EMPTY);
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
let config: LocalConfig = {} as any;
|
|
|
|
let git: SimpleGit | undefined;
|
|
let gitInitialized: boolean;
|
|
|
|
let privateKeySet = false;
|
|
|
|
async function fetchBranchCommits(): Promise<void> {
|
|
config.branchCommits = {};
|
|
const opts = ['ls-remote', '--heads', config.url];
|
|
if (config.extraCloneOpts) {
|
|
Object.entries(config.extraCloneOpts).forEach((e) =>
|
|
opts.unshift(e[0], `${e[1]}`)
|
|
);
|
|
}
|
|
try {
|
|
(await git.raw(opts))
|
|
.split('\n')
|
|
.filter(Boolean)
|
|
.map((line) => line.trim().split(/\s+/))
|
|
.forEach(([sha, ref]) => {
|
|
config.branchCommits[ref.replace('refs/heads/', '')] = sha;
|
|
});
|
|
} catch (err) /* istanbul ignore next */ {
|
|
logger.debug({ err }, 'git error');
|
|
if (err.message?.includes('Please ask the owner to check their account')) {
|
|
throw new Error(REPOSITORY_DISABLED);
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
export async function initRepo(args: StorageConfig): Promise<void> {
|
|
config = { ...args } as any;
|
|
config.ignoredAuthors = [];
|
|
config.additionalBranches = [];
|
|
config.branchIsModified = {};
|
|
const { localDir } = getGlobalConfig();
|
|
git = Git(localDir);
|
|
gitInitialized = false;
|
|
await fetchBranchCommits();
|
|
}
|
|
|
|
async function resetToBranch(branchName: string): Promise<void> {
|
|
logger.debug(`resetToBranch(${branchName})`);
|
|
await git.raw(['reset', '--hard']);
|
|
await git.checkout(branchName);
|
|
await git.raw(['reset', '--hard', 'origin/' + branchName]);
|
|
await git.raw(['clean', '-fd']);
|
|
}
|
|
|
|
async function deleteLocalBranch(branchName: string): Promise<void> {
|
|
await git.branch(['-D', branchName]);
|
|
}
|
|
|
|
async function cleanLocalBranches(): Promise<void> {
|
|
const existingBranches = (await git.raw(['branch']))
|
|
.split('\n')
|
|
.map((branch) => branch.trim())
|
|
.filter((branch) => branch.length)
|
|
.filter((branch) => !branch.startsWith('* '));
|
|
logger.debug({ existingBranches });
|
|
for (const branchName of existingBranches) {
|
|
await deleteLocalBranch(branchName);
|
|
}
|
|
}
|
|
|
|
/*
|
|
* When we initially clone, we clone only the default branch so how no knowledge of other branches existing.
|
|
* By calling this function once the repo's branchPrefix is known, we can fetch all of Renovate's branches in one command.
|
|
*/
|
|
async function setBranchPrefix(branchPrefix: string): Promise<void> {
|
|
config.branchPrefix = branchPrefix;
|
|
// If the repo is already cloned then set branchPrefix now, otherwise it will be called again during syncGit()
|
|
if (gitInitialized) {
|
|
logger.debug('Setting branchPrefix: ' + branchPrefix);
|
|
const ref = `refs/heads/${branchPrefix}*:refs/remotes/origin/${branchPrefix}*`;
|
|
try {
|
|
await git.fetch(['origin', ref, '--depth=5', '--force']);
|
|
} catch (err) /* istanbul ignore next */ {
|
|
checkForPlatformFailure(err);
|
|
throw err;
|
|
}
|
|
}
|
|
}
|
|
|
|
export function setGitAuthor(gitAuthor: string): void {
|
|
const gitAuthorParsed = parseGitAuthor(
|
|
gitAuthor || 'Renovate Bot <renovate@whitesourcesoftware.com>'
|
|
);
|
|
if (!gitAuthorParsed) {
|
|
const error = new Error(CONFIG_VALIDATION);
|
|
error.validationSource = 'None';
|
|
error.validationError = 'Invalid gitAuthor';
|
|
error.validationMessage = `gitAuthor is not parsed as valid RFC5322 format: ${gitAuthor}`;
|
|
throw error;
|
|
}
|
|
config.gitAuthorName = gitAuthorParsed.name;
|
|
config.gitAuthorEmail = gitAuthorParsed.address;
|
|
}
|
|
|
|
export async function writeGitAuthor(): Promise<void> {
|
|
const { gitAuthorName, gitAuthorEmail } = config;
|
|
try {
|
|
if (gitAuthorName) {
|
|
logger.debug({ gitAuthorName }, 'Setting git author name');
|
|
await git.addConfig('user.name', gitAuthorName);
|
|
}
|
|
if (gitAuthorEmail) {
|
|
logger.debug({ gitAuthorEmail }, 'Setting git author email');
|
|
await git.addConfig('user.email', gitAuthorEmail);
|
|
}
|
|
} catch (err) /* istanbul ignore next */ {
|
|
checkForPlatformFailure(err);
|
|
logger.debug(
|
|
{ err, gitAuthorName, gitAuthorEmail },
|
|
'Error setting git author config'
|
|
);
|
|
throw new Error(TEMPORARY_ERROR);
|
|
}
|
|
}
|
|
|
|
export async function setUserRepoConfig({
|
|
branchPrefix,
|
|
gitIgnoredAuthors,
|
|
gitAuthor,
|
|
}: RenovateConfig): Promise<void> {
|
|
await setBranchPrefix(branchPrefix);
|
|
config.ignoredAuthors = gitIgnoredAuthors ?? [];
|
|
setGitAuthor(gitAuthor);
|
|
}
|
|
|
|
export async function getSubmodules(): Promise<string[]> {
|
|
try {
|
|
return (
|
|
(await git.raw([
|
|
'config',
|
|
'--file',
|
|
'.gitmodules',
|
|
'--get-regexp',
|
|
'\\.path',
|
|
])) || ''
|
|
)
|
|
.trim()
|
|
.split(/[\n\s]/)
|
|
.filter((_e: string, i: number) => i % 2);
|
|
} catch (err) /* istanbul ignore next */ {
|
|
logger.warn({ err }, 'Error getting submodules');
|
|
return [];
|
|
}
|
|
}
|
|
|
|
export async function syncGit(): Promise<void> {
|
|
if (gitInitialized) {
|
|
return;
|
|
}
|
|
gitInitialized = true;
|
|
const { localDir } = getGlobalConfig();
|
|
logger.debug('Initializing git repository into ' + localDir);
|
|
const gitHead = join(localDir, '.git/HEAD');
|
|
let clone = true;
|
|
|
|
if (await fs.exists(gitHead)) {
|
|
try {
|
|
await git.raw(['remote', 'set-url', 'origin', config.url]);
|
|
await resetToBranch(await getDefaultBranch(git));
|
|
const fetchStart = Date.now();
|
|
await git.pull();
|
|
await git.fetch(['--depth=10']);
|
|
config.currentBranch =
|
|
config.currentBranch || (await getDefaultBranch(git));
|
|
await resetToBranch(config.currentBranch);
|
|
await cleanLocalBranches();
|
|
await git.raw(['remote', 'prune', 'origin']);
|
|
const durationMs = Math.round(Date.now() - fetchStart);
|
|
logger.info({ durationMs }, 'git fetch completed');
|
|
clone = false;
|
|
} catch (err) /* istanbul ignore next */ {
|
|
if (err.message === REPOSITORY_EMPTY) {
|
|
throw err;
|
|
}
|
|
logger.info({ err }, 'git fetch error');
|
|
}
|
|
}
|
|
if (clone) {
|
|
await fs.emptyDir(localDir);
|
|
const cloneStart = Date.now();
|
|
try {
|
|
// clone only the default branch
|
|
const opts = ['--depth=10'];
|
|
if (config.extraCloneOpts) {
|
|
Object.entries(config.extraCloneOpts).forEach((e) =>
|
|
opts.push(e[0], `${e[1]}`)
|
|
);
|
|
}
|
|
await git.clone(config.url, '.', opts);
|
|
} catch (err) /* istanbul ignore next */ {
|
|
logger.debug({ err }, 'git clone error');
|
|
if (err.message?.includes('No space left on device')) {
|
|
throw new Error(SYSTEM_INSUFFICIENT_DISK_SPACE);
|
|
}
|
|
if (err.message === REPOSITORY_EMPTY) {
|
|
throw err;
|
|
}
|
|
throw new ExternalHostError(err, 'git');
|
|
}
|
|
const durationMs = Math.round(Date.now() - cloneStart);
|
|
logger.debug({ durationMs }, 'git clone completed');
|
|
}
|
|
config.currentBranchSha = (await git.raw(['rev-parse', 'HEAD'])).trim();
|
|
if (config.cloneSubmodules) {
|
|
const submodules = await getSubmodules();
|
|
for (const submodule of submodules) {
|
|
try {
|
|
logger.debug(`Cloning git submodule at ${submodule}`);
|
|
await git.submoduleUpdate(['--init', submodule]);
|
|
} catch (err) {
|
|
logger.warn(
|
|
{ err },
|
|
`Unable to initialise git submodule at ${submodule}`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
try {
|
|
const latestCommit = (await git.log({ n: 1 })).latest;
|
|
logger.debug({ latestCommit }, 'latest repository commit');
|
|
} catch (err) /* istanbul ignore next */ {
|
|
checkForPlatformFailure(err);
|
|
if (err.message.includes('does not have any commits yet')) {
|
|
throw new Error(REPOSITORY_EMPTY);
|
|
}
|
|
logger.warn({ err }, 'Cannot retrieve latest commit');
|
|
}
|
|
config.currentBranch = config.currentBranch || (await getDefaultBranch(git));
|
|
if (config.branchPrefix) {
|
|
await setBranchPrefix(config.branchPrefix);
|
|
}
|
|
}
|
|
|
|
// istanbul ignore next
|
|
export async function getRepoStatus(): Promise<StatusResult> {
|
|
await syncGit();
|
|
return git.status();
|
|
}
|
|
|
|
async function syncBranch(branchName: string): Promise<void> {
|
|
await syncGit();
|
|
if (branchName.startsWith(config.branchPrefix)) {
|
|
return;
|
|
}
|
|
if (config.additionalBranches.includes(branchName)) {
|
|
return;
|
|
}
|
|
config.additionalBranches.push(branchName);
|
|
// fetch the branch only if it's not part of the existing branchPrefix
|
|
try {
|
|
await git.raw(['remote', 'set-branches', '--add', 'origin', branchName]);
|
|
await git.fetch(['origin', branchName, '--depth=5']);
|
|
} catch (err) /* istanbul ignore next */ {
|
|
checkForPlatformFailure(err);
|
|
}
|
|
}
|
|
|
|
export function branchExists(branchName: string): boolean {
|
|
return !!config.branchCommits[branchName];
|
|
}
|
|
|
|
// Return the commit SHA for a branch
|
|
export function getBranchCommit(branchName: string): CommitSha | null {
|
|
return config.branchCommits[branchName] || null;
|
|
}
|
|
|
|
// Return the parent commit SHA for a branch
|
|
export async function getBranchParentSha(
|
|
branchName: string
|
|
): Promise<CommitSha | null> {
|
|
try {
|
|
const branchSha = getBranchCommit(branchName);
|
|
const parentSha = await git.revparse([`${branchSha}^`]);
|
|
return parentSha;
|
|
} catch (err) {
|
|
logger.debug({ err }, 'Error getting branch parent sha');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function getCommitMessages(): Promise<string[]> {
|
|
await syncGit();
|
|
logger.debug('getCommitMessages');
|
|
const res = await git.log({
|
|
n: 10,
|
|
format: { message: '%s' },
|
|
});
|
|
return res.all.map((commit) => commit.message);
|
|
}
|
|
|
|
export async function checkoutBranch(branchName: string): Promise<CommitSha> {
|
|
logger.debug(`Setting current branch to ${branchName}`);
|
|
await syncBranch(branchName);
|
|
try {
|
|
config.currentBranch = branchName;
|
|
config.currentBranchSha = (
|
|
await git.raw(['rev-parse', 'origin/' + branchName])
|
|
).trim();
|
|
await git.checkout(['-f', branchName, '--']);
|
|
const latestCommitDate = (await git.log({ n: 1 }))?.latest?.date;
|
|
if (latestCommitDate) {
|
|
logger.debug({ branchName, latestCommitDate }, 'latest commit');
|
|
}
|
|
await git.reset(ResetMode.HARD);
|
|
return config.currentBranchSha;
|
|
} catch (err) /* istanbul ignore next */ {
|
|
checkForPlatformFailure(err);
|
|
if (err.message?.includes('fatal: ambiguous argument')) {
|
|
logger.warn({ err }, 'Failed to checkout branch');
|
|
throw new Error(TEMPORARY_ERROR);
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
export async function getFileList(): Promise<string[]> {
|
|
await syncGit();
|
|
const branch = config.currentBranch;
|
|
const submodules = await getSubmodules();
|
|
let files: string;
|
|
try {
|
|
files = await git.raw(['ls-tree', '-r', branch]);
|
|
} catch (err) /* istanbul ignore next */ {
|
|
if (err.message?.includes('fatal: Not a valid object name')) {
|
|
logger.debug(
|
|
{ err },
|
|
'Branch not found when checking branch list - aborting'
|
|
);
|
|
throw new Error(REPOSITORY_CHANGED);
|
|
}
|
|
throw err;
|
|
}
|
|
// istanbul ignore if
|
|
if (!files) {
|
|
return [];
|
|
}
|
|
return files
|
|
.split('\n')
|
|
.filter(Boolean)
|
|
.filter((line) => line.startsWith('100'))
|
|
.map((line) => line.split(/\t/).pop())
|
|
.filter((file: string) =>
|
|
submodules.every((submodule: string) => !file.startsWith(submodule))
|
|
);
|
|
}
|
|
|
|
export function getBranchList(): string[] {
|
|
return Object.keys(config.branchCommits);
|
|
}
|
|
|
|
export async function isBranchStale(branchName: string): Promise<boolean> {
|
|
await syncBranch(branchName);
|
|
try {
|
|
const { currentBranchSha, currentBranch } = config;
|
|
const branches = await git.branch([
|
|
'--remotes',
|
|
'--verbose',
|
|
'--contains',
|
|
config.currentBranchSha,
|
|
]);
|
|
const isStale = !branches.all.map(localName).includes(branchName);
|
|
logger.debug(
|
|
{ isStale, branches, currentBranch, currentBranchSha },
|
|
`IsBranchStale=${isStale}`
|
|
);
|
|
return isStale;
|
|
} catch (err) /* istanbul ignore next */ {
|
|
checkForPlatformFailure(err);
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
export async function isBranchModified(branchName: string): Promise<boolean> {
|
|
await syncBranch(branchName);
|
|
// First check cache
|
|
if (config.branchIsModified[branchName] !== undefined) {
|
|
return config.branchIsModified[branchName];
|
|
}
|
|
if (!branchExists(branchName)) {
|
|
logger.debug(
|
|
{ branchName },
|
|
'Branch does not exist - cannot check isModified'
|
|
);
|
|
return false;
|
|
}
|
|
// Retrieve the author of the most recent commit
|
|
let lastAuthor: string;
|
|
try {
|
|
lastAuthor = (
|
|
await git.raw([
|
|
'log',
|
|
'-1',
|
|
'--pretty=format:%ae',
|
|
`origin/${branchName}`,
|
|
'--',
|
|
])
|
|
).trim();
|
|
} catch (err) /* istanbul ignore next */ {
|
|
if (err.message?.includes('fatal: bad revision')) {
|
|
logger.debug(
|
|
{ err },
|
|
'Remote branch not found when checking last commit author - aborting run'
|
|
);
|
|
throw new Error(REPOSITORY_CHANGED);
|
|
}
|
|
logger.warn({ err }, 'Error checking last author for isBranchModified');
|
|
}
|
|
const { gitAuthorEmail } = config;
|
|
if (
|
|
lastAuthor === gitAuthorEmail ||
|
|
config.ignoredAuthors.some((ignoredAuthor) => lastAuthor === ignoredAuthor)
|
|
) {
|
|
// author matches - branch has not been modified
|
|
config.branchIsModified[branchName] = false;
|
|
return false;
|
|
}
|
|
logger.debug(
|
|
{ branchName, lastAuthor, gitAuthorEmail },
|
|
'Last commit author does not match git author email - branch has been modified'
|
|
);
|
|
config.branchIsModified[branchName] = true;
|
|
return true;
|
|
}
|
|
|
|
export async function deleteBranch(branchName: string): Promise<void> {
|
|
await syncBranch(branchName);
|
|
try {
|
|
await git.raw(['push', '--delete', 'origin', branchName]);
|
|
logger.debug({ branchName }, 'Deleted remote branch');
|
|
} catch (err) /* istanbul ignore next */ {
|
|
checkForPlatformFailure(err);
|
|
logger.debug({ branchName }, 'No remote branch to delete');
|
|
}
|
|
try {
|
|
await deleteLocalBranch(branchName);
|
|
// istanbul ignore next
|
|
logger.debug({ branchName }, 'Deleted local branch');
|
|
} catch (err) {
|
|
checkForPlatformFailure(err);
|
|
logger.debug({ branchName }, 'No local branch to delete');
|
|
}
|
|
delete config.branchCommits[branchName];
|
|
}
|
|
|
|
export async function mergeBranch(branchName: string): Promise<void> {
|
|
let status;
|
|
try {
|
|
await syncBranch(branchName);
|
|
await git.reset(ResetMode.HARD);
|
|
await git.checkout(['-B', branchName, 'origin/' + branchName]);
|
|
await git.checkout([
|
|
'-B',
|
|
config.currentBranch,
|
|
'origin/' + config.currentBranch,
|
|
]);
|
|
status = await git.status();
|
|
await git.merge(['--ff-only', branchName]);
|
|
await git.push('origin', config.currentBranch);
|
|
incLimitedValue(Limit.Commits);
|
|
} catch (err) {
|
|
logger.debug(
|
|
{
|
|
baseBranch: config.currentBranch,
|
|
baseSha: config.currentBranchSha,
|
|
branchName,
|
|
branchSha: getBranchCommit(branchName),
|
|
status,
|
|
err,
|
|
},
|
|
'mergeBranch error'
|
|
);
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
export async function getBranchLastCommitTime(
|
|
branchName: string
|
|
): Promise<Date> {
|
|
await syncBranch(branchName);
|
|
try {
|
|
const time = await git.show(['-s', '--format=%ai', 'origin/' + branchName]);
|
|
return new Date(Date.parse(time));
|
|
} catch (err) {
|
|
checkForPlatformFailure(err);
|
|
return new Date();
|
|
}
|
|
}
|
|
|
|
export async function getBranchFiles(branchName: string): Promise<string[]> {
|
|
await syncBranch(branchName);
|
|
try {
|
|
const diff = await git.diffSummary([
|
|
`origin/${branchName}`,
|
|
`origin/${branchName}^`,
|
|
]);
|
|
return diff.files.map((file) => file.file);
|
|
} catch (err) /* istanbul ignore next */ {
|
|
logger.warn({ err }, 'getBranchFiles error');
|
|
checkForPlatformFailure(err);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function getFile(
|
|
filePath: string,
|
|
branchName?: string
|
|
): Promise<string | null> {
|
|
await syncGit();
|
|
try {
|
|
const content = await git.show([
|
|
'origin/' + (branchName || config.currentBranch) + ':' + filePath,
|
|
]);
|
|
return content;
|
|
} catch (err) {
|
|
checkForPlatformFailure(err);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function hasDiff(branchName: string): Promise<boolean> {
|
|
await syncBranch(branchName);
|
|
try {
|
|
return (await git.diff(['HEAD', branchName])) !== '';
|
|
} catch (err) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* File to commit
|
|
*/
|
|
export interface File {
|
|
/**
|
|
* Relative file path
|
|
*/
|
|
name: string;
|
|
|
|
/**
|
|
* file contents
|
|
*/
|
|
contents: string | Buffer;
|
|
}
|
|
|
|
export type CommitFilesConfig = {
|
|
branchName: string;
|
|
files: File[];
|
|
message: string;
|
|
force?: boolean;
|
|
};
|
|
|
|
export async function commitFiles({
|
|
branchName,
|
|
files,
|
|
message,
|
|
force = false,
|
|
}: CommitFilesConfig): Promise<CommitSha | null> {
|
|
await syncGit();
|
|
logger.debug(`Committing files to branch ${branchName}`);
|
|
if (!privateKeySet) {
|
|
await writePrivateKey();
|
|
privateKeySet = true;
|
|
}
|
|
const { localDir } = getGlobalConfig();
|
|
await configSigningKey(localDir);
|
|
await writeGitAuthor();
|
|
try {
|
|
await git.reset(ResetMode.HARD);
|
|
await git.raw(['clean', '-fd']);
|
|
await git.checkout(['-B', branchName, 'origin/' + config.currentBranch]);
|
|
const deletedFiles: string[] = [];
|
|
const addedModifiedFiles: string[] = [];
|
|
const ignoredFiles: string[] = [];
|
|
for (const file of files) {
|
|
let fileName = file.name;
|
|
// istanbul ignore if
|
|
if (fileName === '|delete|') {
|
|
fileName = file.contents as string;
|
|
try {
|
|
await git.rm([fileName]);
|
|
deletedFiles.push(fileName);
|
|
} catch (err) /* istanbul ignore next */ {
|
|
checkForPlatformFailure(err);
|
|
logger.warn({ err, fileName }, 'Cannot delete file');
|
|
ignoredFiles.push(fileName);
|
|
}
|
|
} else if (await isDirectory(join(localDir, fileName))) {
|
|
logger.warn({ fileName }, 'Skipping directory commit');
|
|
ignoredFiles.push(fileName);
|
|
} else {
|
|
let contents: Buffer;
|
|
// istanbul ignore else
|
|
if (typeof file.contents === 'string') {
|
|
contents = Buffer.from(file.contents);
|
|
} else {
|
|
contents = file.contents;
|
|
}
|
|
await fs.outputFile(join(localDir, fileName), contents);
|
|
try {
|
|
await git.add(fileName);
|
|
addedModifiedFiles.push(fileName);
|
|
} catch (err) /* istanbul ignore next */ {
|
|
if (
|
|
!err.message.includes(
|
|
'The following paths are ignored by one of your .gitignore files'
|
|
)
|
|
) {
|
|
throw err;
|
|
}
|
|
logger.debug({ fileName }, 'Cannot commit ignored file');
|
|
ignoredFiles.push(file.name);
|
|
}
|
|
}
|
|
}
|
|
|
|
const commitOptions: Options = {};
|
|
if (getNoVerify().includes(GitNoVerifyOption.Commit)) {
|
|
commitOptions['--no-verify'] = null;
|
|
}
|
|
|
|
const commitRes = await git.commit(message, [], commitOptions);
|
|
if (
|
|
commitRes.summary &&
|
|
commitRes.summary.changes === 0 &&
|
|
commitRes.summary.insertions === 0 &&
|
|
commitRes.summary.deletions === 0
|
|
) {
|
|
logger.warn({ commitRes }, 'Detected empty commit - aborting git push');
|
|
return null;
|
|
}
|
|
logger.debug(
|
|
{ deletedFiles, ignoredFiles, result: commitRes },
|
|
`git commit`
|
|
);
|
|
const commit = commitRes?.commit || 'unknown';
|
|
if (!force && !(await hasDiff(`origin/${branchName}`))) {
|
|
logger.debug(
|
|
{ branchName, deletedFiles, addedModifiedFiles, ignoredFiles },
|
|
'No file changes detected. Skipping commit'
|
|
);
|
|
return null;
|
|
}
|
|
|
|
const pushOptions: TaskOptions = {
|
|
'--force': null,
|
|
'-u': null,
|
|
};
|
|
if (getNoVerify().includes(GitNoVerifyOption.Push)) {
|
|
pushOptions['--no-verify'] = null;
|
|
}
|
|
|
|
const pushRes = await git.push(
|
|
'origin',
|
|
`${branchName}:${branchName}`,
|
|
pushOptions
|
|
);
|
|
delete pushRes.repo;
|
|
logger.debug({ result: pushRes }, 'git push');
|
|
// Fetch it after create
|
|
const ref = `refs/heads/${branchName}:refs/remotes/origin/${branchName}`;
|
|
await git.fetch(['origin', ref, '--depth=5', '--force']);
|
|
config.branchCommits[branchName] = (
|
|
await git.revparse([branchName])
|
|
).trim();
|
|
config.branchIsModified[branchName] = false;
|
|
incLimitedValue(Limit.Commits);
|
|
return commit;
|
|
} catch (err) /* istanbul ignore next */ {
|
|
checkForPlatformFailure(err);
|
|
if (err.message.includes(`'refs/heads/renovate' exists`)) {
|
|
const error = new Error(CONFIG_VALIDATION);
|
|
error.validationSource = 'None';
|
|
error.validationError = 'An existing branch is blocking Renovate';
|
|
error.validationMessage = `Renovate needs to create the branch "${branchName}" but is blocked from doing so because of an existing branch called "renovate". Please remove it so that Renovate can proceed.`;
|
|
throw error;
|
|
}
|
|
if (
|
|
err.message.includes(
|
|
'refusing to allow a GitHub App to create or update workflow'
|
|
)
|
|
) {
|
|
logger.warn(
|
|
'App has not been granted permissions to update Workflows - aborting branch.'
|
|
);
|
|
return null;
|
|
}
|
|
if (
|
|
(err.message.includes('remote rejected') ||
|
|
err.message.includes('403')) &&
|
|
files?.some((file) => file.name?.startsWith('.github/workflows/'))
|
|
) {
|
|
logger.debug({ err }, 'commitFiles error');
|
|
logger.info('Workflows update rejection - aborting branch.');
|
|
return null;
|
|
}
|
|
if (err.message.includes('protected branch hook declined')) {
|
|
const error = new Error(CONFIG_VALIDATION);
|
|
error.validationSource = branchName;
|
|
error.validationError = 'Renovate branch is protected';
|
|
error.validationMessage = `Renovate cannot push to its branch because branch protection has been enabled.`;
|
|
throw error;
|
|
}
|
|
if (err.message.includes('can only push your own commits')) {
|
|
const error = new Error(CONFIG_VALIDATION);
|
|
error.validationSource = branchName;
|
|
error.validationError = 'Bitbucket committer error';
|
|
error.validationMessage = `Renovate has experienced the following error when attempting to push its branch to the server: "${String(
|
|
err.message
|
|
)}"`;
|
|
throw error;
|
|
}
|
|
if (err.message.includes('remote: error: cannot lock ref')) {
|
|
logger.error({ err }, 'Error committing files.');
|
|
return null;
|
|
}
|
|
logger.debug({ err }, 'Unknown error committing files');
|
|
// We don't know why this happened, so this will cause bubble up to a branch error
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
export function getUrl({
|
|
protocol,
|
|
auth,
|
|
hostname,
|
|
host,
|
|
repository,
|
|
}: {
|
|
protocol?: GitProtocol;
|
|
auth?: string;
|
|
hostname?: string;
|
|
host?: string;
|
|
repository: string;
|
|
}): string {
|
|
if (protocol === 'ssh') {
|
|
return `git@${hostname}:${repository}.git`;
|
|
}
|
|
return URL.format({
|
|
protocol: protocol || 'https',
|
|
auth,
|
|
hostname,
|
|
host,
|
|
pathname: repository + '.git',
|
|
});
|
|
}
|