import { setTimeout } from 'node:timers/promises'; import URL from 'node:url'; import { isBoolean, isNonEmptyObject, isNonEmptyStringAndNotWhitespace, isString, isTruthy, } from '@sindresorhus/is'; import fs from 'fs-extra'; import { DateTime } from 'luxon'; import semver from 'semver'; import type { Options, SimpleGit, SimpleGitOptions, TaskOptions, } from 'simple-git'; import { ResetMode, simpleGit } from 'simple-git'; import upath from 'upath'; import { getConfigFileNames } from '../../config/app-strings.ts'; import { GlobalConfig } from '../../config/global.ts'; import type { RenovateConfig } from '../../config/types.ts'; import { CONFIG_VALIDATION, INVALID_PATH, REPOSITORY_CHANGED, REPOSITORY_DISABLED, REPOSITORY_EMPTY, SYSTEM_INSUFFICIENT_DISK_SPACE, TEMPORARY_ERROR, UNKNOWN_ERROR, } from '../../constants/error-messages.ts'; import { instrument } from '../../instrumentation/index.ts'; import { withInstrumenting } from '../../instrumentation/with-instrumenting.ts'; import { logger } from '../../logger/index.ts'; import { ExternalHostError } from '../../types/errors/external-host-error.ts'; import type { GitProtocol } from '../../types/git.ts'; import { incCountValue, incLimitedValue } from '../../workers/global/limits.ts'; import { coerceArray } from '../array.ts'; import { getCache } from '../cache/repository/index.ts'; import { getEnv } from '../env.ts'; import type { ExtraEnv } from '../exec/types.ts'; import { getChildEnv } from '../exec/utils.ts'; import { coerceObject } from '../object.ts'; import { newlineRegex, regEx } from '../regex.ts'; import type { LongCommitSha } from '../schema-utils/git.ts'; import { toLongCommitSha } from '../schema-utils/git.ts'; import { matchRegexOrGlobList } from '../string-match.ts'; import { logWarningIfUnicodeHiddenCharactersInPackageFile } from '../unicode.ts'; import { getGitEnvironmentVariables } from './auth.ts'; import { parseGitAuthor } from './author.ts'; import { getCachedBehindBaseResult, setCachedBehindBaseResult, } from './behind-base-branch-cache.ts'; import { formatCommitMessage } from './commit-trailers.ts'; import { getNoVerify, simpleGitConfig } from './config.ts'; import { getCachedConflictResult, setCachedConflictResult, } from './conflicts-cache.ts'; import { bulkChangesDisallowed, checkForPlatformFailure, handleCommitError, } from './error.ts'; import type { InstrumentedSimpleGit } from './instrument.ts'; import { instrumentGit } from './instrument.ts'; import { getCachedModifiedResult, setCachedModifiedResult, } from './modified-cache.ts'; import { configSigningKey, writePrivateKey } from './private-key.ts'; import type { CommitFilesConfig, CommitResult, DiffTreeItem, GitObjectType, LocalConfig, PushFilesConfig, StatusResult, StorageConfig, } from './types.ts'; import { GitTreeMode } from './types.ts'; import { getCachedUpdateDateResult, setCachedUpdateDateResult, } from './update-date-cache.ts'; export { setNoVerify } from './config.ts'; export { setPrivateKey } from './private-key.ts'; // Retry parameters const retryCount = 5; const delaySeconds = 3; const delayFactor = 2; export const RENOVATE_FORK_UPSTREAM = 'renovate-fork-upstream'; interface CreateSimpleGitOptions { config?: Partial; env?: ExtraEnv; authentication?: { hostTypes?: readonly string[]; }; } export function createSimpleGit({ config, env, authentication, }: CreateSimpleGitOptions = {}): SimpleGit { const childEnv = getChildEnv({ extraEnv: { // Git will prompt for known hosts or passwords, unless we activate BatchMode. // Set as extraEnv (lowest priority) so that process.env and // customEnvVariables can override it. GIT_SSH_COMMAND: 'ssh -o BatchMode=yes', }, env: { ...env, // To ensure the simple-git parsers match correctly, we need // to set the `LANG` and `LC_ALL` environment variables to // the `C.UTF-8` locale. See the docs for more details: // https://github.com/steveukx/git-js/blob/1bb14df0595794a9353d28ccdaeeb06c0b9bf2a5/docs/NON_ENGLISH_LOCALE.md // // Use "C.UTF-8" instead of just "C" (as specified in docs) to handle special characters: // https://github.com/renovatebot/renovate/pull/18963 LANG: 'C.UTF-8', LC_ALL: 'C.UTF-8', }, }); const gitEnv = authentication ? getGitEnvironmentVariables(childEnv, authentication.hostTypes) : childEnv; return simpleGit({ ...simpleGitConfig(), ...config }).env(gitEnv); } // A generic wrapper for simpleGit.* calls to make them more fault-tolerant export async function gitRetry(gitFunc: () => Promise): Promise { let round = 0; let lastError: Error | undefined; while (round <= retryCount) { if (round > 0) { logger.debug(`gitRetry round ${round}`); } try { const res = await gitFunc(); if (round > 1) { logger.debug('Successful retry of git function'); } return res; } catch (err) { lastError = err; logger.debug({ err }, `Git function thrown`); // Try to transform the Error to ExternalHostError const errChecked = checkForPlatformFailure(err); if (errChecked instanceof ExternalHostError) { logger.debug( { err: errChecked }, `ExternalHostError thrown in round ${ round + 1 } of ${retryCount} - retrying in the next round`, ); } else { throw err; } } const nextDelay = delayFactor ^ ((round - 1) * delaySeconds); logger.trace({ nextDelay }, `Delay next round`); await setTimeout(1000 * nextDelay); round++; } throw lastError!; } async function isDirectory(dir: string): Promise { try { return (await fs.stat(dir)).isDirectory(); } catch { return false; } } async function getDefaultBranch(git: InstrumentedSimpleGit): Promise { logger.debug('getDefaultBranch()'); // see https://stackoverflow.com/a/62352647/3005034 try { let res = await git.raw(['rev-parse', '--abbrev-ref', 'origin/HEAD']); /* v8 ignore next -- TODO: add test #40625 */ if (!res) { logger.debug('Could not determine default branch using git rev-parse'); const headPrefix = 'HEAD branch: '; res = (await git.raw(['remote', 'show', 'origin'])) .split('\n') .map((line) => line.trim()) .find((line) => line.startsWith(headPrefix))! .replace(headPrefix, ''); } return res.replace('origin/', '').trim(); } catch (err) /* v8 ignore next -- TODO: add test #40625 */ { logger.debug({ err }, 'Error getting default branch'); const errChecked = checkForPlatformFailure(err); if (errChecked) { throw errChecked; } if ( err.message.startsWith( 'fatal: ref refs/remotes/origin/HEAD is not a symbolic ref', ) ) { throw new Error(REPOSITORY_EMPTY); } if (err.message.includes("fatal: ambiguous argument 'origin/HEAD'")) { logger.warn('Error getting default branch'); throw new Error(TEMPORARY_ERROR); } throw err; } } let config: LocalConfig = {} as any; // TODO: can be undefined let git: InstrumentedSimpleGit; let gitInitialized: boolean; let submodulesInitizialized: boolean; let privateKeySet = false; let platformIgnoredAuthors: string[] = []; export const GIT_MINIMUM_VERSION = '2.33.0'; // git show-current export async function validateGitVersion(): Promise { let version: string | undefined; const globalGit = instrumentGit(createSimpleGit()); try { const { major, minor, patch, installed } = await globalGit.version(); /* v8 ignore if -- TODO: add test #40625 */ if (!installed) { logger.error('Git not installed'); return false; } version = `${major}.${minor}.${patch}`; } catch (err) /* v8 ignore next -- TODO: add test #40625 */ { logger.error({ err }, 'Error fetching git version'); return false; } /* v8 ignore if -- TODO: add test #40625 */ if (!(version && semver.gte(version, GIT_MINIMUM_VERSION))) { logger.error( { detectedVersion: version, minimumVersion: GIT_MINIMUM_VERSION }, 'Git version needs upgrading', ); return false; } logger.debug(`Found valid git version: ${version}`); return true; } async function fetchBranchCommits(preferUpstream = true): Promise { config.branchCommits = {}; const url = preferUpstream && config.upstreamUrl ? config.upstreamUrl : config.url; logger.debug(`fetchBranchCommits(): url=${url}`); const opts = ['ls-remote', '--heads', url]; const localDir = GlobalConfig.get('localDir'); const repoExists = await fs.pathExists(upath.join(localDir, '.git/HEAD')); if (config.extraCloneOpts && !repoExists) { Object.entries(config.extraCloneOpts).forEach((e) => // TODO: types (#22198) opts.unshift(e[0], `${e[1]!}`), ); } try { const lsRemoteRes = await gitRetry(() => git.raw(opts)); logger.trace({ lsRemoteRes }, 'git ls-remote result'); lsRemoteRes .split(newlineRegex) .filter(isTruthy) .map((line) => line.trim().split(regEx(/\s+/))) .forEach(([sha, ref]) => { config.branchCommits[ref.replace('refs/heads/', '')] = toLongCommitSha(sha); }); if (isNonEmptyObject(config.virtualBranches)) { for (const [name, branch] of Object.entries(config.virtualBranches)) { config.branchCommits[name] = branch.sha; } } logger.trace({ branchCommits: config.branchCommits }, 'branch commits'); } catch (err) /* v8 ignore next -- TODO: add test #40625 */ { const errChecked = checkForPlatformFailure(err); if (errChecked) { throw errChecked; } 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 fetchRevSpec(...revSpec: string[]): Promise { await gitRetry(() => git.fetch(['origin', ...revSpec])); } export async function initRepo(args: StorageConfig): Promise { config = { ...args } as any; config.ignoredAuthors = []; config.additionalBranches = []; config.branchIsModified = {}; config.virtualBranches ??= {}; git = instrumentGit( createSimpleGit({ config: { baseDir: GlobalConfig.get('localDir') } }), ); gitInitialized = false; submodulesInitizialized = false; await fetchBranchCommits(); } async function resetToBranch(branchName: string): Promise { logger.debug(`resetToBranch(${branchName})`); await git.raw(['reset', '--hard']); await gitRetry(() => git.checkout(branchName)); await git.raw(['reset', '--hard', `origin/${branchName}`]); await git.raw(['clean', '-fd']); } /* v8 ignore next -- TODO: add test #40625 */ export async function resetToCommit(commit: LongCommitSha): Promise { logger.debug(`resetToCommit(${commit})`); await git.raw(['reset', '--hard', commit]); } async function deleteLocalBranch(branchName: string): Promise { await git.branch(['-D', branchName]); } async function cleanLocalBranches(): Promise { const existingBranches = (await git.raw(['branch'])) .split(newlineRegex) .map((branch) => branch.trim()) .filter((branch) => branch.length > 0 && !branch.startsWith('* ')); logger.debug({ existingBranches }); for (const branchName of existingBranches) { await deleteLocalBranch(branchName); } } export function setGitAuthor(gitAuthor: string | undefined): void { const gitAuthorParsed = parseGitAuthor( gitAuthor ?? 'Renovate ', ); 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 { const { gitAuthorName, gitAuthorEmail, writeGitDone } = config; /* v8 ignore if -- TODO: add test #40625 */ if (writeGitDone) { return; } config.writeGitDone = true; try { // v8 ignore else -- TODO: add test #40625 if (gitAuthorName) { logger.debug(`Setting git author name: ${gitAuthorName}`); await git.addConfig('user.name', gitAuthorName); } // v8 ignore else -- TODO: add test #40625 if (gitAuthorEmail) { logger.debug(`Setting git author email: ${gitAuthorEmail}`); await git.addConfig('user.email', gitAuthorEmail); } } catch (err) /* v8 ignore next -- TODO: add test #40625 */ { const errChecked = checkForPlatformFailure(err); if (errChecked) { throw errChecked; } logger.debug( { err, gitAuthorName, gitAuthorEmail }, 'Error setting git author config', ); throw new Error(TEMPORARY_ERROR); } } export function setUserRepoConfig({ gitIgnoredAuthors, gitAuthor, }: RenovateConfig): void { config.ignoredAuthors = coerceArray(gitIgnoredAuthors); setGitAuthor(gitAuthor); } export function setPlatformIgnoredAuthors(emails: string[] = []): void { platformIgnoredAuthors = emails; } export async function getSubmodules(): Promise { try { return ( (await git.raw([ 'config', '--file', '.gitmodules', '--get-regexp', '\\.path', ])) || '' ) .trim() .split(regEx(/[\n\s]/)) .filter((_e: string, i: number) => i % 2); } catch (err) /* v8 ignore next -- TODO: add test #40625 */ { logger.warn({ err }, 'Error getting submodules'); return []; } } export async function cloneSubmodules( shouldClone: boolean, cloneSubmodulesFilter: string[] | undefined, ): Promise { if (!shouldClone || submodulesInitizialized) { return; } submodulesInitizialized = true; const gitEnv = getGitEnvironmentVariables(getChildEnv()); await syncGit(); const submodules = await getSubmodules(); for (const submodule of submodules) { if (!matchRegexOrGlobList(submodule, cloneSubmodulesFilter ?? ['*'])) { logger.debug( { cloneSubmodulesFilter }, `Skipping submodule ${submodule}`, ); continue; } try { logger.debug(`Cloning git submodule at ${submodule}`); await gitRetry(() => git.env(gitEnv).submoduleUpdate(['--init', '--recursive', submodule]), ); } catch (err) { logger.warn({ err, submodule }, `Unable to initialise git submodule`); } } } export function isCloned(): boolean { return gitInitialized; } export async function isFileModeEnabled(): Promise { if (config.fileModeEnabled === undefined) { const value = await git.raw([ 'config', '--type=bool', '--default=true', '--get', 'core.fileMode', ]); config.fileModeEnabled = value.trim() === 'true'; } return config.fileModeEnabled; } export const syncGit = withInstrumenting( { name: 'syncGit' }, async (): Promise => { if (gitInitialized) { if (getEnv().RENOVATE_X_CLEAR_HOOKS) { await git.raw(['config', 'core.hooksPath', '/dev/null']); } return; } /* v8 ignore if -- TODO: add test #40625 */ if (GlobalConfig.get('platform') === 'local') { throw new Error('Cannot sync git when platform=local'); } gitInitialized = true; const localDir = GlobalConfig.get('localDir'); logger.debug(`syncGit(): Initializing git repository into ${localDir}`); const gitHead = upath.join(localDir, '.git/HEAD'); let clone = true; if (await fs.pathExists(gitHead)) { await instrument('fetch', async () => { logger.debug( `syncGit(): Found existing git repository, attempting git fetch`, ); try { await git.raw(['remote', 'set-url', 'origin', config.url]); const fetchStart = Date.now(); await gitRetry(() => git.fetch(['--prune', 'origin'])); config.currentBranch = config.currentBranch || (await getDefaultBranch(git)); await resetToBranch(config.currentBranch); await cleanLocalBranches(); const durationMs = Math.round(Date.now() - fetchStart); logger.info({ durationMs }, 'git fetch completed'); clone = false; } catch (err) /* v8 ignore next -- TODO: add test #40625 */ { if (err.message === REPOSITORY_EMPTY) { throw err; } logger.info({ err }, 'git fetch error, falling back to git clone'); } }); } if (clone) { await instrument('clone', async () => { const cloneStart = Date.now(); try { const opts: string[] = []; if (config.defaultBranch) { opts.push('-b', config.defaultBranch); } if (config.fullClone) { logger.debug('Performing full clone'); } else { logger.debug('Performing blobless clone'); opts.push('--filter=blob:none'); } if (config.extraCloneOpts) { Object.entries(config.extraCloneOpts).forEach((e) => // TODO: types (#22198) opts.push(e[0], `${e[1]!}`), ); } async function emptyDirAndClone(): Promise { await instrument(`fs.emptyDir(${localDir})`, () => fs.emptyDir(localDir), ); await git.clone(config.url, '.', opts); } await gitRetry(() => instrument('emptyDirAndClone', emptyDirAndClone), ); } catch (err) /* v8 ignore next -- TODO: add test #40625 */ { 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'); }); } try { config.currentBranchSha = toLongCommitSha( (await git.raw(['rev-parse', 'HEAD'])).trim(), ); } catch (err) /* v8 ignore next -- TODO: add test #40625 */ { if (err.message?.includes('fatal: not a git repository')) { throw new Error(REPOSITORY_CHANGED); } throw err; } // This will only happen now if set in global config await instrument('cloneSubmodules', () => cloneSubmodules(!!config.cloneSubmodules, config.cloneSubmodulesFilter), ); try { const latestCommit = (await git.log({ n: 1 })).latest; logger.debug({ latestCommit }, 'latest repository commit'); } catch (err) /* v8 ignore next -- TODO: add test #40625 */ { const errChecked = checkForPlatformFailure(err); if (errChecked) { throw errChecked; } 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 ?? config.defaultBranch ?? (await getDefaultBranch(git)); /* v8 ignore next -- TODO: add test #40625 */ delete getCache()?.semanticCommits; // If upstreamUrl is set then Renovate is running in fork mode // The "upstream" remote is the original repository which was forked from if (config.upstreamUrl) { const { upstreamUrl } = config; await instrument('sync with upstreamUrl', async () => { logger.debug( `Bringing default branch up-to-date with ${RENOVATE_FORK_UPSTREAM}, to get latest config`, ); // Add remote if it does not exist const remotes = await git.getRemotes(true); // v8 ignore else -- TODO: add test #40625 if (!remotes.some((remote) => remote.name === RENOVATE_FORK_UPSTREAM)) { logger.debug(`Adding remote ${RENOVATE_FORK_UPSTREAM}`); await git.addRemote(RENOVATE_FORK_UPSTREAM, upstreamUrl); } await syncForkWithUpstream(config.currentBranch); await fetchBranchCommits(false); }); } if (isNonEmptyObject(config.virtualBranches)) { const virtualBranches = config.virtualBranches; await instrument('fetch virtual branches', async () => { const refSpecs = Object.entries(virtualBranches).map( ([name, branch]) => `${branch.ref}:${remoteBranchRef(name)}`, ); await fetchRevSpec(...refSpecs); logger.debug( `Fetched ${Object.keys(virtualBranches).length} virtual branches`, ); }); } config.currentBranchSha = toLongCommitSha( (await git.revparse('HEAD')).trim(), ); logger.debug(`Current branch SHA: ${config.currentBranchSha}`); }, ); export async function getRepoStatus(path?: string): Promise { if (isString(path)) { const localDir = GlobalConfig.get('localDir'); const localPath = upath.resolve(localDir, path); if (!localPath.startsWith(upath.resolve(localDir))) { logger.warn( { localPath, localDir }, 'Preventing access to file outside the local directory', ); throw new Error(INVALID_PATH); } } await syncGit(); return git.status(path ? [path] : []); } export function branchExists(branchName: string): boolean { return !!config.branchCommits[branchName]; } // Return the commit SHA for a branch export function getBranchCommit(branchName: string): LongCommitSha | null { return config.branchCommits?.[branchName] || null; } // Return the date of the latest commit for a branch export async function getBranchUpdateDate( branchName: string, ): Promise { const branchSha = config.branchCommits[branchName]; if (!branchSha) { return null; } const updateDate = getCachedUpdateDateResult(branchName, branchSha); if (updateDate !== null) { logger.debug( `getBranchUpdateDate(): using cached result "${updateDate.toISO()}"`, ); return updateDate; } await syncGit(); try { const result = await getCommitDate(branchSha); setCachedUpdateDateResult(branchName, result); return result; } catch (err) { logger.debug({ err, branchName }, 'Error getting branch update date'); return null; } } // Return the commit date of every remote branch tip. // Uses a a single `git for-each-ref` call, instead of spawning a `git show` per branch export async function getAllBranchUpdateDates(): Promise< Record > { logger.debug('getAllBranchUpdateDates'); await syncGit(); const raw = await git.raw([ 'for-each-ref', '--format=%(refname:short) %(committerdate:iso-strict)', // NOTE that using `origin/` (instead of i.e. `origin/*`) allows us to capture nested branch names 'refs/remotes/origin/', ]); const result: Record = {}; const lines = raw .trim() .split(newlineRegex) .filter(isNonEmptyStringAndNotWhitespace); for (const line of lines) { const [refShort, isoDate] = line.split(' '); // refs/remotes/origin/HEAD, the default branch for the repo, is shortened to `origin` if (refShort === 'origin') { continue; } const branchName = refShort.replace(regEx(/^origin\//), ''); result[branchName] = DateTime.fromISO(isoDate).toUTC(); } return result; } export async function getCommitMessages(): Promise { logger.debug('getCommitMessages'); // v8 ignore else -- TODO: add test #40625 if (GlobalConfig.get('platform') !== 'local') { await syncGit(); } try { const res = await git.log({ n: 20, format: { message: '%s' }, '--no-merges': null, }); return res.all.map((commit) => commit.message); } catch /* v8 ignore next -- TODO: add test #40625 */ { return []; } } export async function checkoutBranch( branchName: string, ): Promise { logger.debug(`Setting current branch to ${branchName}`); await syncGit(); try { await gitRetry(() => git.checkout( submodulesInitizialized ? ['-f', '--recurse-submodules', branchName, '--'] : ['-f', branchName, '--'], ), ); config.currentBranch = branchName; config.currentBranchSha = toLongCommitSha( (await git.raw(['rev-parse', 'HEAD'])).trim(), ); const latestCommitDate = await getCommitDate(config.currentBranchSha); // v8 ignore else -- TODO: add test #40625 if (latestCommitDate) { logger.debug( { branchName, latestCommitDate, sha: config.currentBranchSha }, 'latest commit', ); } await git.reset(ResetMode.HARD); return config.currentBranchSha; } catch (err) /* v8 ignore next -- TODO: add test #40625 */ { const errChecked = checkForPlatformFailure(err); if (errChecked) { throw errChecked; } if (err.message?.includes('fatal: ambiguous argument')) { logger.warn({ err }, 'Failed to checkout branch'); throw new Error(TEMPORARY_ERROR); } throw err; } } export async function checkoutBranchFromRemote( branchName: string, remoteName: string, ): Promise { logger.debug(`Checking out branch ${branchName} from remote ${remoteName}`); await syncGit(); try { await gitRetry(() => git.checkoutBranch(branchName, `${remoteName}/${branchName}`), ); config.currentBranch = branchName; config.currentBranchSha = toLongCommitSha( (await git.revparse('HEAD')).trim(), ); logger.debug(`Checked out branch ${branchName} from remote ${remoteName}`); config.branchCommits[branchName] = config.currentBranchSha; return config.currentBranchSha; } catch (err) { const errChecked = checkForPlatformFailure(err); /* v8 ignore if -- TODO: add test #40625 */ if (errChecked) { throw errChecked; } if (err.message?.includes('fatal: ambiguous argument')) { logger.warn({ err }, 'Failed to checkout branch'); throw new Error(TEMPORARY_ERROR); } throw err; } } /** * Returns the remote-tracking ref path for a virtual branch. */ export function remoteBranchRef(branchName: string): string { return `refs/remotes/origin/${branchName}`; } /** * Returns whether the given branch is tracked as a virtual branch. */ function isVirtualBranch(branchName: string): boolean { return branchName in config.virtualBranches; } /** * Set a virtual branch's remote-tracking ref and add it to the * virtual-branch registry (config.virtualBranches). * * Used after pushing (to keep tracking in sync with the remote) and in * createPr() when the Gerrit change ref becomes available. * * @param branchName Virtual branch name to set * @param ref The ref this virtual branch is fetched from (e.g., 'refs/changes/34/1234/1') * @param commitSha The commit SHA the virtual branch points to. */ export async function setVirtualBranch( branchName: string, ref: string, commitSha: LongCommitSha, ): Promise { logger.debug({ branchName, ref, commitSha }, 'setVirtualBranch()'); await git.raw(['update-ref', remoteBranchRef(branchName), commitSha]); config.branchCommits[branchName] = commitSha; config.virtualBranches[branchName] = { ref, sha: commitSha }; config.branchIsModified[branchName] = false; } export async function resetHardFromRemote( remoteAndBranch: string, ): Promise { try { const resetLog = await git.reset(['--hard', remoteAndBranch]); logger.debug({ resetLog }, 'git reset log'); } catch (err) { logger.error({ err }, 'Error during git reset --hard'); throw err; } } export async function forcePushToRemote( branchName: string, remote: string, ): Promise { try { const pushLog = await git.push([remote, branchName, '--force']); logger.debug({ pushLog }, 'git push log'); } catch (err) { logger.error({ err }, 'Error during git push --force'); throw err; } } export async function getFileList(): Promise { await syncGit(); const branch = config.currentBranch; let files: string; try { files = await git.raw(['ls-tree', '-r', `refs/heads/${branch}`]); } catch (err) /* v8 ignore next -- TODO: add test #40625 */ { 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; } /* v8 ignore if -- TODO: add test #40625 */ if (!files) { return []; } // submodules are starting with `160000 commit` return files .split(newlineRegex) .filter(isString) .filter((line) => line.startsWith('100')) .map((line) => line.split(regEx(/\t/)).pop()!); } export function getBranchList(): string[] { return Object.keys(coerceObject(config.branchCommits)); } export async function isBranchBehindBase( branchName: string, baseBranch: string, ): Promise { const baseBranchSha = getBranchCommit(baseBranch); const branchSha = getBranchCommit(branchName); let isBehind = getCachedBehindBaseResult( branchName, branchSha, baseBranch, baseBranchSha, ); if (isBehind !== null) { logger.debug(`branch.isBehindBase(): using cached result "${isBehind}"`); return isBehind; } logger.debug( `branch.isBehindBase(): using git to calculate against baseBranch "${baseBranch}"`, ); await syncGit(); try { const behindCount = ( await git.raw(['rev-list', '--count', `${branchSha!}..${baseBranchSha!}`]) ).trim(); isBehind = behindCount !== '0'; logger.debug( { baseBranch, branchName }, `branch.isBehindBase(): ${isBehind}`, ); setCachedBehindBaseResult(branchName, isBehind); return isBehind; } catch (err) /* v8 ignore next -- TODO: add test #40625 */ { const errChecked = checkForPlatformFailure(err); if (errChecked) { throw errChecked; } throw err; } } export async function isBranchModified( branchName: string, baseBranch: string, ): Promise { if (!branchExists(branchName)) { logger.debug('branch.isModified(): no cache'); return false; } // First check local config if (config.branchIsModified[branchName] !== undefined) { return config.branchIsModified[branchName]; } // Second check repository cache const isModified = getCachedModifiedResult( branchName, getBranchCommit(branchName), // branch sha ); if (isModified !== null) { logger.debug(`branch.isModified(): using cached result "${isModified}"`); config.branchIsModified[branchName] = isModified; return isModified; } logger.debug( `branch.isModified(): using git to calculate against baseBranch "${baseBranch}"`, ); await syncGit(); const committedAuthors = new Set(); try { const commits = await git.log({ from: `origin/${baseBranch}`, to: `origin/${branchName}`, symmetric: false, // means .. instead of ... format: { author_email: '%ae', committer_email: '%ce', }, }); for (const commit of commits.all) { committedAuthors.add(commit.author_email); committedAuthors.add(commit.committer_email); } } catch (err) /* v8 ignore next -- TODO: add test #40625 */ { 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, ignoredAuthors } = config; const includedAuthors = new Set(); for (const committedAuthor of committedAuthors) { if ( committedAuthor !== gitAuthorEmail && !ignoredAuthors.includes(committedAuthor) && !matchRegexOrGlobList(committedAuthor, ignoredAuthors) ) { includedAuthors.add(committedAuthor); } } for (const ignoredAuthor of platformIgnoredAuthors) { includedAuthors.delete(ignoredAuthor); } if (includedAuthors.size === 0) { // authors all match - branch has not been modified logger.trace( { branchName, baseBranch, committedAuthors: [...committedAuthors], includedAuthors: [...includedAuthors], gitAuthorEmail, ignoredAuthors, }, 'branch.isModified() = false', ); logger.debug('branch.isModified() = false'); config.branchIsModified[branchName] = false; setCachedModifiedResult(branchName, false); return false; } logger.trace( { branchName, baseBranch, committedAuthors: [...committedAuthors], includedAuthors: [...includedAuthors], gitAuthorEmail, ignoredAuthors, }, 'branch.isModified() = true', ); logger.debug( { baseBranch, branchName, unrecognizedAuthors: [...includedAuthors] }, 'branch.isModified() = true', ); config.branchIsModified[branchName] = true; setCachedModifiedResult(branchName, true); return true; } export async function isBranchConflicted( baseBranch: string, branch: string, ): Promise { logger.debug(`isBranchConflicted(${baseBranch}, ${branch})`); const baseBranchSha = getBranchCommit(baseBranch); const branchSha = getBranchCommit(branch); if (!baseBranchSha || !branchSha) { logger.warn( { baseBranch, branch }, 'isBranchConflicted: branch does not exist', ); return true; } const isConflicted = getCachedConflictResult( branch, branchSha, baseBranch, baseBranchSha, ); if (isBoolean(isConflicted)) { logger.debug( `branch.isConflicted(): using cached result "${isConflicted}"`, ); return isConflicted; } logger.debug( `branch.isConflicted(): using git to calculate against baseBranch ${baseBranch}`, ); let result = false; await syncGit(); await writeGitAuthor(); const origBranch = config.currentBranch; try { await git.reset(ResetMode.HARD); //TODO: see #18600 if (origBranch !== baseBranch) { await git.checkout(baseBranch); } await git.merge(['--no-commit', '--no-ff', `origin/${branch}`]); } catch (err) { result = true; /* v8 ignore if -- TODO: add test #40625 */ if (!err?.git?.conflicts?.length) { logger.debug( { baseBranch, branch, err }, 'isBranchConflicted: unknown error', ); } } finally { try { await git.merge(['--abort']); if (origBranch !== baseBranch) { await git.checkout(origBranch); } } catch (err) /* v8 ignore next -- TODO: add test #40625 */ { logger.debug( { baseBranch, branch, err }, 'isBranchConflicted: cleanup error', ); } } setCachedConflictResult(branch, result); logger.debug(`branch.isConflicted(): ${result}`); return result; } export async function deleteBranch(branchName: string): Promise { await syncGit(); if (isVirtualBranch(branchName)) { // Delete the remote-tracking ref. // Note: git update-ref -d succeeds even if the ref doesn't exist. const ref = remoteBranchRef(branchName); logger.debug({ branchName, ref }, 'Deleting virtual branch'); await git.raw(['update-ref', '-d', ref]); delete config.virtualBranches[branchName]; } else { try { const deleteCommand = ['push', '--delete', 'origin', branchName]; if (getNoVerify().includes('push')) { deleteCommand.push('--no-verify'); } await gitRetry(() => git.raw(deleteCommand)); logger.debug(`Deleted remote branch: ${branchName}`); } catch (err) { const errChecked = checkForPlatformFailure(err); /* v8 ignore if -- TODO: add test #40625 */ if (errChecked) { throw errChecked; } logger.debug(`No remote branch to delete with name: ${branchName}`); } } try { await deleteLocalBranch(branchName); /* v8 ignore next -- TODO: add test #40625 (always throws) */ logger.debug(`Deleted local branch: ${branchName}`); } catch (err) { const errChecked = checkForPlatformFailure(err); /* v8 ignore if -- TODO: add test #40625 */ if (errChecked) { throw errChecked; } logger.debug(`No local branch to delete with name: ${branchName}`); } delete config.branchCommits[branchName]; } export async function mergeToLocal(branchName: string): Promise { let status: StatusResult | undefined; try { await syncGit(); await writeGitAuthor(); await git.reset(ResetMode.HARD); await gitRetry(() => git.checkout([ '-B', config.currentBranch, `origin/${config.currentBranch}`, ]), ); status = await git.status(); if (isVirtualBranch(branchName)) { // Virtual branches are already local and tracked as a remote-tracking ref, // so merge it directly without fetching from origin. const ref = remoteBranchRef(branchName); logger.debug( { branchName, ref }, 'mergeToLocal(): merging virtual branch', ); await git.merge([ref]); } else { await fetchRevSpec(branchName); await gitRetry(() => git.merge(['FETCH_HEAD'])); } } catch (err) { logger.debug( { baseBranch: config.currentBranch, baseSha: config.currentBranchSha, branchName, status, err, }, 'mergeLocally error', ); throw err; } } export async function mergeBranch(branchName: string): Promise { let status: StatusResult | undefined; try { await syncGit(); await writeGitAuthor(); await git.reset(ResetMode.HARD); await gitRetry(() => git.checkout(['-B', branchName, `origin/${branchName}`]), ); await gitRetry(() => git.checkout([ '-B', config.currentBranch, `origin/${config.currentBranch}`, ]), ); status = await git.status(); await gitRetry(() => git.merge(['--ff-only', branchName])); await gitRetry(() => git.push('origin', config.currentBranch)); incLimitedValue('Commits'); } catch (err) { logger.debug( { baseBranch: config.currentBranch, baseSha: config.currentBranchSha, branchName, branchSha: getBranchCommit(branchName), status, err, }, 'mergeBranch error', ); throw err; } } async function getCommitDate(ref: LongCommitSha | string): Promise { const output = await git.show(['-s', '--format=%cI', ref]); return DateTime.fromISO(output.trim()).toUTC(); } export async function getBranchLastCommitTime( branchName: string, ): Promise { await syncGit(); try { const time = await getCommitDate(`origin/${branchName}`); return time.toJSDate(); } catch (err) { const errChecked = checkForPlatformFailure(err); /* v8 ignore next -- TODO: add test */ if (errChecked) { throw errChecked; } return DateTime.now().toJSDate(); } } export function getBranchFiles(branchName: string): Promise { return getBranchFilesFromRef(`origin/${branchName}`); } export function getBranchFilesFromCommit( referenceCommit: LongCommitSha, ): Promise { return getBranchFilesFromRef(referenceCommit); } async function getBranchFilesFromRef( refName: string, ): Promise { await syncGit(); try { const diff = await gitRetry(() => git.diffSummary([refName, `${refName}^`]), ); return diff.files.map((file) => file.file); } catch (err) /* v8 ignore next -- TODO: add test #40625 */ { logger.warn({ err }, 'getBranchFilesFromRef error'); const errChecked = checkForPlatformFailure(err); if (errChecked) { throw errChecked; } return null; } } export async function getFile( filePath: string, branchName?: string, ): Promise { await syncGit(); try { const content = await git.show([ `origin/${branchName ?? config.currentBranch}:${filePath}`, ]); logWarningIfUnicodeHiddenCharactersInPackageFile(filePath, content); return content; } catch (err) { const errChecked = checkForPlatformFailure(err); /* v8 ignore if -- TODO: add test #40625 */ if (errChecked) { throw errChecked; } return null; } } export async function getFiles( fileNames: string[], ): Promise> { const fileContentMap: Record = {}; for (const fileName of fileNames) { fileContentMap[fileName] = await getFile(fileName); } return fileContentMap; } export async function hasDiff( sourceRef: string, targetRef: string, ): Promise { await syncGit(); try { return ( (await gitRetry(() => git.diff([sourceRef, targetRef, '--']))) !== '' ); } catch { return true; } } async function handleCommitAuth(localDir: string): Promise { if (!privateKeySet) { await writePrivateKey(); privateKeySet = true; } await configSigningKey(localDir); await writeGitAuthor(); } /** * * Prepare local branch with commit * * 0. Hard reset * 1. Creates local branch with `origin/` prefix * 2. Perform `git add` (respecting mode) and `git remove` for each file * 3. Perform commit * 4. Check whether resulting commit is empty or not (due to .gitignore) * 5. If not empty, return commit info for further processing * */ export async function prepareCommit({ branchName, files, message, trailers, force = false, }: CommitFilesConfig): Promise { const localDir = GlobalConfig.get('localDir'); await syncGit(); logger.debug(`Preparing files for committing to branch ${branchName}`); await handleCommitAuth(localDir); try { await git.reset(ResetMode.HARD); await git.raw(['clean', '-fd']); const parentCommitSha = config.currentBranchSha; await gitRetry(() => git.checkout(['-B', branchName, `origin/${config.currentBranch}`]), ); const deletedFiles: string[] = []; const addedModifiedFiles: string[] = []; const ignoredFiles: string[] = []; for (const file of files) { const fileName = file.path; if (file.type === 'deletion') { try { await git.rm([fileName]); deletedFiles.push(fileName); } catch (err) /* v8 ignore next -- TODO: add test #40625 */ { const errChecked = checkForPlatformFailure(err); if (errChecked) { throw errChecked; } logger.trace({ err, fileName }, 'Cannot delete file'); ignoredFiles.push(fileName); } } else { if (await isDirectory(upath.join(localDir, fileName))) { // This is usually a git submodule update logger.trace({ fileName }, 'Adding directory commit'); } else if (file.contents === null) { continue; } else { let contents: Buffer; /* v8 ignore else -- TODO: add test #40625 */ if (isString(file.contents)) { contents = Buffer.from(file.contents); } else { contents = file.contents; } // some file systems including Windows don't support the mode // so the index should be manually updated after adding the file if (file.isSymlink) { await fs.symlink(file.contents, upath.join(localDir, fileName)); } else { await fs.outputFile(upath.join(localDir, fileName), contents, { mode: file.isExecutable ? 0o777 : 0o666, }); } } try { /* v8 ignore next -- TODO: add test #40625 */ const addParams = fileName === getConfigFileNames()[0] ? ['-f', fileName] : fileName; await git.add(addParams); if (file.isExecutable) { await git.raw(['update-index', '--chmod=+x', fileName]); } addedModifiedFiles.push(fileName); } catch (err) /* v8 ignore next -- TODO: add test #40625 */ { if ( !err.message.includes( 'The following paths are ignored by one of your .gitignore files', ) ) { throw err; } logger.debug(`Cannot commit ignored file: ${fileName}`); ignoredFiles.push(file.path); } } } const commitOptions: Options = {}; if (getNoVerify().includes('commit')) { commitOptions['--no-verify'] = null; } const commitMessage = formatCommitMessage(message, trailers); const commitRes = await git.commit(commitMessage, [], commitOptions); if ( isNonEmptyObject(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`, ); if (!force && !(await hasDiff('HEAD', `origin/${branchName}`))) { logger.debug( { branchName, deletedFiles, addedModifiedFiles, ignoredFiles }, 'No file changes detected. Skipping commit', ); return null; } const commitSha = toLongCommitSha( (await git.revparse([branchName])).trim(), ); const result: CommitResult = { parentCommitSha, commitSha, files: files.filter((fileChange) => { if (fileChange.type === 'deletion') { return deletedFiles.includes(fileChange.path); } return addedModifiedFiles.includes(fileChange.path); }), }; return result; } catch (err) /* v8 ignore next -- TODO: add test #40625 */ { return handleCommitError(err, branchName, files); } } export async function pushCommit({ sourceRef, targetRef, files, pushOptions, }: PushFilesConfig): Promise { await syncGit(); logger.debug(`Pushing refSpec ${sourceRef}:${targetRef ?? sourceRef}`); let result = false; try { const gitOptions: TaskOptions = { '--force-with-lease': null, '-u': null, }; if (getNoVerify().includes('push')) { gitOptions['--no-verify'] = null; } if (pushOptions) { gitOptions['--push-option'] = pushOptions; } const pushRes = await gitRetry(() => git.push('origin', `${sourceRef}:${targetRef ?? sourceRef}`, gitOptions), ); delete pushRes.repo; logger.debug({ result: pushRes }, 'git push'); incLimitedValue('Commits'); incCountValue('HourlyCommits'); result = true; } catch (err) /* v8 ignore next -- TODO: add test #40625 */ { handleCommitError(err, sourceRef, files); } return result; } export async function fetchBranch( branchName: string, ): Promise { await syncGit(); logger.debug(`Fetching branch ${branchName}`); try { const ref = `refs/heads/${branchName}:${remoteBranchRef(branchName)}`; await gitRetry(() => git.pull(['origin', ref, '--force'])); const commit = toLongCommitSha((await git.revparse([branchName])).trim()); config.branchCommits[branchName] = commit; config.branchIsModified[branchName] = false; return commit; } catch (err) /* v8 ignore next -- TODO: add test #40625 */ { return handleCommitError(err, branchName); } } export async function commitFiles( commitConfig: CommitFilesConfig, ): Promise { try { const commitResult = await prepareCommit(commitConfig); if (commitResult) { const pushResult = await pushCommit({ sourceRef: commitConfig.branchName, files: commitConfig.files, }); // v8 ignore else -- TODO: add test #40625 if (pushResult) { const { branchName } = commitConfig; const { commitSha } = commitResult; config.branchCommits[branchName] = commitSha; config.branchIsModified[branchName] = false; return commitSha; } } return null; } catch (err) /* v8 ignore next -- TODO: add test #40625 */ { if (err.message.includes('[rejected] (stale info)')) { throw new Error(REPOSITORY_CHANGED); } throw err; } } export function getUrl({ protocol, auth, hostname, host, repository, }: { protocol?: GitProtocol; auth?: string; hostname?: string; host?: string; repository: string; }): string { if (protocol === 'ssh') { // TODO: types (#22198) return `git@${hostname!}:${repository}.git`; } return URL.format({ protocol: protocol ?? 'https', auth, hostname, host, pathname: `${repository}.git`, }); } let remoteRefsExist = false; /** * * Non-branch refs allow us to store git objects without triggering CI pipelines. * It's useful for API-based branch rebasing. * * @see https://stackoverflow.com/questions/63866947/pushing-git-non-branch-references-to-a-remote/63868286 * */ export async function pushCommitToRenovateRef( commitSha: string, refName: string, ): Promise { const fullRefName = `refs/renovate/branches/${refName}`; await git.raw(['update-ref', fullRefName, commitSha]); await git.push(['--force', 'origin', fullRefName]); remoteRefsExist = true; } /** * * Removes all remote "refs/renovate/branches/*" refs in two steps: * * Step 1: list refs * * $ git ls-remote origin "refs/renovate/branches/*" * * > cca38e9ea6d10946bdb2d0ca5a52c205783897aa refs/renovate/branches/foo * > 29ac154936c880068994e17eb7f12da7fdca70e5 refs/renovate/branches/bar * > 3fafaddc339894b6d4f97595940fd91af71d0355 refs/renovate/branches/baz * > ... * * Step 2: * * $ git push --delete origin refs/renovate/branches/foo refs/renovate/branches/bar refs/renovate/branches/baz * * If Step 2 fails because the repo doesn't allow bulk changes, we'll remove them one by one instead: * * $ git push --delete origin refs/renovate/branches/foo * $ git push --delete origin refs/renovate/branches/bar * $ git push --delete origin refs/renovate/branches/baz */ export async function clearRenovateRefs(): Promise { if (!gitInitialized || !remoteRefsExist) { return; } logger.debug(`Cleaning up Renovate refs: refs/renovate/branches/*`); const renovateRefs: string[] = []; try { const rawOutput = await git.listRemote([ config.url, 'refs/renovate/branches/*', ]); const refs = rawOutput .split(newlineRegex) .map((line) => line.replace(regEx(/[0-9a-f]+\s+/i), '').trim()) .filter((line) => line.startsWith('refs/renovate/branches/')); renovateRefs.push(...refs); } catch (err) /* v8 ignore next -- TODO: add test #40625 */ { logger.warn({ err }, `Renovate refs cleanup error`); } // v8 ignore else -- TODO: add test #40625 if (renovateRefs.length) { try { const pushOpts = ['--delete', 'origin', ...renovateRefs]; await git.push(pushOpts); } catch (err) { /* v8 ignore else -- TODO: add test #40625 */ if (bulkChangesDisallowed(err)) { for (const ref of renovateRefs) { try { const pushOpts = ['--delete', 'origin', ref]; await git.push(pushOpts); } catch (err) /* v8 ignore next -- TODO: add test #40625 */ { logger.debug({ err }, 'Error deleting "refs/renovate/branches/*"'); break; } } } else { logger.warn({ err }, 'Error deleting "refs/renovate/branches/*"'); } } } remoteRefsExist = false; } const diffTreeLineRegex = regEx( /^:(?\d{6})\s+(?\d{6})\s+(?[0-9a-f]{40})\s+(?[0-9a-f]{40})\s+(?[A-Z]\d*)\t(?.+)$/, ); const treeShaRegex = regEx(/tree\s+(?[0-9a-f]{40})\s*/); /** * Get the tree SHA for a commit. */ export async function getCommitTreeSha( commitSha: LongCommitSha, ): Promise { const commitOutput = await git.catFile(['-p', commitSha]); const { treeSha } = coerceObject(treeShaRegex.exec(commitOutput)?.groups); if (!treeSha) { const snippet = commitOutput.split(newlineRegex)[0]; /* v8 ignore next -- tested, but v8 reports template literal as partial */ throw new Error( `Could not extract tree SHA from commit ${commitSha}: ${snippet}`, ); } return toLongCommitSha(treeSha); } function treeTypeFromMode(mode: string): GitObjectType { switch (mode) { case GitTreeMode.Gitlink: return 'commit'; case GitTreeMode.Directory: return 'tree'; default: return 'blob'; } } /** * Return only the files that changed between two commits. * Deletions have `sha: null` (for use with GitHub's `base_tree` API). */ export async function diffCommitTree( parentCommitSha: LongCommitSha, commitSha: LongCommitSha, ): Promise { const output = await git.raw([ 'diff-tree', '-M', '-r', '--no-commit-id', parentCommitSha, commitSha, ]); const result: DiffTreeItem[] = []; for (const line of output.split(newlineRegex)) { const matchGroups = diffTreeLineRegex.exec(line)?.groups; if (matchGroups) { const { oldMode, newMode, newSha, status, paths } = matchGroups; const statusCode = status[0]; // R has two tab-separated paths (old\tnew); A/M/D/T have one. // C also has two paths but falls through to default (only the target matters). const [sourcePath, targetPath] = paths.split('\t'); switch (statusCode) { case 'D': result.push({ path: sourcePath, mode: oldMode, type: treeTypeFromMode(oldMode), sha: null, }); break; case 'R': // Rename: delete source, add target result.push({ path: sourcePath, mode: oldMode, type: treeTypeFromMode(oldMode), sha: null, }); result.push({ path: targetPath, mode: newMode, type: treeTypeFromMode(newMode), sha: toLongCommitSha(newSha), }); break; default: // A (add), M (modify), T (type change), C (copy) result.push({ path: targetPath ?? sourcePath, mode: newMode, type: treeTypeFromMode(newMode), sha: toLongCommitSha(newSha), }); break; } } } return result; } async function localBranchExists(branchName: string): Promise { await syncGit(); const localBranches = await git.branchLocal(); return localBranches.all.includes(branchName); } /** * Synchronize a forked branch with its upstream counterpart. * * syncForkWithUpstream updates the fork's branch, to match the corresponding branch in the upstream repository. * The steps are: * 1. Check if the branch exists locally. * 2. If the branch exists locally: checkout the local branch. * 3. If the branch does _not_ exist locally: checkout the upstream branch. * 4. Reset the local branch to match the upstream branch. * 5. Force push the (updated) local branch to the origin repository. * * @param {string} branchName - The name of the branch to synchronize. * @returns A promise that resolves to True if the synchronization is successful, or `false` if an error occurs. */ export async function syncForkWithUpstream(branchName: string): Promise { if (!config.upstreamUrl) { return; } logger.debug( `Synchronizing fork with "${RENOVATE_FORK_UPSTREAM}" remote for branch ${branchName}`, ); const remotes = await getRemotes(); /* v8 ignore if -- this should not be possible if upstreamUrl exists */ if (!remotes.some((r) => r === RENOVATE_FORK_UPSTREAM)) { throw new Error('No upstream remote exists, cannot sync fork'); } try { await git.fetch([RENOVATE_FORK_UPSTREAM]); if (await localBranchExists(branchName)) { await checkoutBranch(branchName); } else { await checkoutBranchFromRemote(branchName, RENOVATE_FORK_UPSTREAM); } await resetHardFromRemote(`${RENOVATE_FORK_UPSTREAM}/${branchName}`); await forcePushToRemote(branchName, 'origin'); } catch (err) /* v8 ignore next -- shouldn't happen */ { logger.error({ err }, 'Error synchronizing fork'); throw new Error(UNKNOWN_ERROR); } } export async function getRemotes(): Promise { logger.debug('git.getRemotes()'); try { await syncGit(); const remotes = await git.getRemotes(); logger.debug(`Found remotes: ${remotes.map((r) => r.name).join(', ')}`); return remotes.map((remote) => remote.name); } catch (err) /* v8 ignore next -- git.getRemotes only fails on repo corruption, not simulated in specs */ { logger.error({ err }, 'Error getting remotes'); throw err; } }