UNPKG

renovate

Version:

Automated dependency updates. Flexible so you don't need to be.

1,245 lines (1,244 loc) • 42.9 kB
import { CONFIG_VALIDATION, INVALID_PATH, REPOSITORY_CHANGED, REPOSITORY_DISABLED, REPOSITORY_EMPTY, SYSTEM_INSUFFICIENT_DISK_SPACE, TEMPORARY_ERROR, UNKNOWN_ERROR } from "../../constants/error-messages.js"; import { getEnv } from "../env.js"; import { newlineRegex, regEx } from "../regex.js"; import { getConfigFileNames } from "../../config/app-strings.js"; import { GlobalConfig } from "../../config/global.js"; import { matchRegexOrGlobList } from "../string-match.js"; import { logger } from "../../logger/index.js"; import { getChildEnv } from "../exec/utils.js"; import { ExternalHostError } from "../../types/errors/external-host-error.js"; import { logWarningIfUnicodeHiddenCharactersInPackageFile } from "../unicode.js"; import { instrument } from "../../instrumentation/index.js"; import { getCache } from "../cache/repository/index.js"; import { withInstrumenting } from "../../instrumentation/with-instrumenting.js"; import { incCountValue, incLimitedValue } from "../../workers/global/limits.js"; import { getGitEnvironmentVariables } from "./auth.js"; import { parseGitAuthor } from "./author.js"; import { getCachedBehindBaseResult, setCachedBehindBaseResult } from "./behind-base-branch-cache.js"; import { getNoVerify, setNoVerify, simpleGitConfig } from "./config.js"; import { getCachedConflictResult, setCachedConflictResult } from "./conflicts-cache.js"; import { bulkChangesDisallowed, checkForPlatformFailure, handleCommitError } from "./error.js"; import { instrumentGit } from "./instrument.js"; import { getCachedModifiedResult, setCachedModifiedResult } from "./modified-cache.js"; import { configSigningKey, setPrivateKey, writePrivateKey } from "./private-key.js"; import { GitTreeMode } from "./types.js"; import { getCachedUpdateDateResult, setCachedUpdateDateResult } from "./update-date-cache.js"; import { isBoolean, isNonEmptyObject, isString } from "@sindresorhus/is"; import fs from "fs-extra"; import { DateTime } from "luxon"; import upath from "upath"; import URL from "node:url"; import semver from "semver"; import { setTimeout } from "node:timers/promises"; import { ResetMode, simpleGit } from "simple-git"; //#region lib/util/git/index.ts const retryCount = 5; const delaySeconds = 3; const delayFactor = 2; const RENOVATE_FORK_UPSTREAM = "renovate-fork-upstream"; function createSimpleGit({ config, env } = {}) { return simpleGit({ ...simpleGitConfig(), ...config }).env(getChildEnv({ extraEnv: { GIT_SSH_COMMAND: "ssh -o BatchMode=yes" }, env: { ...env, LANG: "C.UTF-8", LC_ALL: "C.UTF-8" } })); } async function gitRetry(gitFunc) { let round = 0; let lastError; 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`); 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(1e3 * nextDelay); round++; } throw lastError; } async function isDirectory(dir) { try { return (await fs.stat(dir)).isDirectory(); } catch { return false; } } async function getDefaultBranch(git) { logger.debug("getDefaultBranch()"); 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 = {}; let git; let gitInitialized; let submodulesInitizialized; let privateKeySet = false; const GIT_MINIMUM_VERSION = "2.33.0"; async function validateGitVersion() { let version; 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, "2.33.0"))) { 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) { 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) => 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(Boolean).map((line) => line.trim().split(regEx(/\s+/))).forEach(([sha, ref]) => { config.branchCommits[ref.replace("refs/heads/", "")] = 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; } } async function fetchRevSpec(revSpec) { await gitRetry(() => git.fetch(["origin", revSpec])); } async function initRepo(args) { config = { ...args }; config.ignoredAuthors = []; config.additionalBranches = []; config.branchIsModified = {}; git = instrumentGit(createSimpleGit({ config: { baseDir: GlobalConfig.get("localDir") } })); gitInitialized = false; submodulesInitizialized = false; await fetchBranchCommits(); } async function resetToBranch(branchName) { 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 */ async function resetToCommit(commit) { logger.debug(`resetToCommit(${commit})`); await git.raw([ "reset", "--hard", commit ]); } async function deleteLocalBranch(branchName) { await git.branch(["-D", branchName]); } async function cleanLocalBranches() { 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); } function setGitAuthor(gitAuthor) { 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; } async function writeGitAuthor() { 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); } } function setUserRepoConfig({ gitIgnoredAuthors, gitAuthor }) { config.ignoredAuthors = gitIgnoredAuthors ?? []; setGitAuthor(gitAuthor); } async function getSubmodules() { try { return (await git.raw([ "config", "--file", ".gitmodules", "--get-regexp", "\\.path" ]) || "").trim().split(regEx(/[\n\s]/)).filter((_e, i) => i % 2); } catch (err) /* v8 ignore next -- TODO: add test #40625 */ { logger.warn({ err }, "Error getting submodules"); return []; } } async function cloneSubmodules(shouldClone, cloneSubmodulesFilter) { if (!shouldClone || submodulesInitizialized) return; submodulesInitizialized = true; const gitEnv = getChildEnv({ env: getGitEnvironmentVariables() }); 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`); } } } function isCloned() { return gitInitialized; } const syncGit = withInstrumenting({ name: "syncGit" }, async () => { 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 === "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 = []; 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) => opts.push(e[0], `${e[1]}`)); const emptyDirAndClone = async () => { 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 === "empty") throw err; throw new ExternalHostError(err, "git"); } const durationMs = Math.round(Date.now() - cloneStart); logger.debug({ durationMs }, "git clone completed"); }); try { config.currentBranchSha = (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; } 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 (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`); // v8 ignore else -- TODO: add test #40625 if (!(await git.getRemotes(true)).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); }); } config.currentBranchSha = (await git.revparse("HEAD")).trim(); logger.debug(`Current branch SHA: ${config.currentBranchSha}`); }); async function getRepoStatus(path) { 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] : []); } function branchExists(branchName) { return !!config.branchCommits[branchName]; } function getBranchCommit(branchName) { return config.branchCommits?.[branchName] || null; } async function getBranchUpdateDate(branchName) { 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; } } async function getCommitMessages() { logger.debug("getCommitMessages"); // v8 ignore else -- TODO: add test #40625 if (GlobalConfig.get("platform") !== "local") await syncGit(); try { return (await git.log({ n: 20, format: { message: "%s" }, "--no-merges": null })).all.map((commit) => commit.message); } catch /* v8 ignore next -- TODO: add test #40625 */ { return []; } } async function checkoutBranch(branchName) { 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 = (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; } } async function checkoutBranchFromRemote(branchName, remoteName) { logger.debug(`Checking out branch ${branchName} from remote ${remoteName}`); await syncGit(); try { await gitRetry(() => git.checkoutBranch(branchName, `${remoteName}/${branchName}`)); config.currentBranch = branchName; config.currentBranchSha = (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; } } async function resetHardFromRemote(remoteAndBranch) { 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; } } async function forcePushToRemote(branchName, remote) { 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; } } async function getFileList() { await syncGit(); const branch = config.currentBranch; let files; 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 []; return files.split(newlineRegex).filter(isString).filter((line) => line.startsWith("100")).map((line) => line.split(regEx(/\t/)).pop()); } function getBranchList() { return Object.keys(config.branchCommits ?? {}); } async function isBranchBehindBase(branchName, baseBranch) { 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 { isBehind = (await git.raw([ "rev-list", "--count", `${branchSha}..${baseBranchSha}` ])).trim() !== "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; } } async function isBranchModified(branchName, baseBranch) { if (!branchExists(branchName)) { logger.debug("branch.isModified(): no cache"); return false; } if (config.branchIsModified[branchName] !== void 0) return config.branchIsModified[branchName]; const isModified = getCachedModifiedResult(branchName, getBranchCommit(branchName)); 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 = /* @__PURE__ */ new Set(); try { const commits = await git.log([`origin/${baseBranch}..origin/${branchName}`]); for (const commit of commits.all) committedAuthors.add(commit.author_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(committedAuthors); // v8 ignore else -- TODO: add test #40625 if (gitAuthorEmail) includedAuthors.delete(gitAuthorEmail); for (const ignoredAuthor of ignoredAuthors) includedAuthors.delete(ignoredAuthor); if (includedAuthors.size === 0) { 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; } async function isBranchConflicted(baseBranch, branch) { 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); 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; } async function deleteBranch(branchName, options) { await syncGit(); if (!options?.localBranch) 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]; } async function mergeToLocal(refSpecToMerge, options) { let status; 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 (options?.localBranch) await git.merge([refSpecToMerge]); else { await fetchRevSpec(refSpecToMerge); await gitRetry(() => git.merge(["FETCH_HEAD"])); } } catch (err) { logger.debug({ baseBranch: config.currentBranch, baseSha: config.currentBranchSha, refSpecToMerge, status, err }, "mergeLocally error"); throw err; } } async function mergeBranch(branchName) { let status; 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) { const output = await git.show([ "-s", "--format=%cI", ref ]); return DateTime.fromISO(output.trim()).toUTC(); } async function getBranchLastCommitTime(branchName) { await syncGit(); try { return (await getCommitDate(`origin/${branchName}`)).toJSDate(); } catch (err) { const errChecked = checkForPlatformFailure(err); /* v8 ignore next 3 -- TODO: add test */ if (errChecked) throw errChecked; return /* @__PURE__ */ new Date(); } } function getBranchFiles(branchName) { return getBranchFilesFromRef(`origin/${branchName}`); } function getBranchFilesFromCommit(referenceCommit) { return getBranchFilesFromRef(referenceCommit); } async function getBranchFilesFromRef(refName) { await syncGit(); try { return (await gitRetry(() => git.diffSummary([refName, `${refName}^`]))).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; } } async function getFile(filePath, branchName) { 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; } } async function getFiles(fileNames) { const fileContentMap = {}; for (const fileName of fileNames) fileContentMap[fileName] = await getFile(fileName); return fileContentMap; } async function hasDiff(sourceRef, targetRef) { await syncGit(); try { return await gitRetry(() => git.diff([ sourceRef, targetRef, "--" ])) !== ""; } catch { return true; } } async function handleCommitAuth(localDir) { 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 * */ async function prepareCommit({ branchName, files, message, force = false }) { 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 = []; const addedModifiedFiles = []; const ignoredFiles = []; 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))) logger.trace({ fileName }, "Adding directory commit"); else if (file.contents === null) continue; else { let contents; /* v8 ignore else -- TODO: add test #40625 */ if (typeof file.contents === "string") contents = Buffer.from(file.contents); else contents = file.contents; if (file.isSymlink) await fs.symlink(file.contents, upath.join(localDir, fileName)); else await fs.outputFile(upath.join(localDir, fileName), contents, { mode: file.isExecutable ? 511 : 438 }); } 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 = {}; if (getNoVerify().includes("commit")) commitOptions["--no-verify"] = null; const commitRes = await git.commit(message, [], 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; } return { parentCommitSha, commitSha: (await git.revparse([branchName])).trim(), files: files.filter((fileChange) => { if (fileChange.type === "deletion") return deletedFiles.includes(fileChange.path); return addedModifiedFiles.includes(fileChange.path); }) }; } catch (err) /* v8 ignore next -- TODO: add test #40625 */ { return handleCommitError(err, branchName, files); } } async function pushCommit({ sourceRef, targetRef, files, pushOptions }) { await syncGit(); logger.debug(`Pushing refSpec ${sourceRef}:${targetRef ?? sourceRef}`); let result = false; try { const gitOptions = { "--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; } async function fetchBranch(branchName) { await syncGit(); logger.debug(`Fetching branch ${branchName}`); try { const ref = `refs/heads/${branchName}:refs/remotes/origin/${branchName}`; await gitRetry(() => git.pull([ "origin", ref, "--force" ])); const commit = (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); } } async function commitFiles(commitConfig) { try { const commitResult = await prepareCommit(commitConfig); if (commitResult) { // v8 ignore else -- TODO: add test #40625 if (await pushCommit({ sourceRef: commitConfig.branchName, files: commitConfig.files })) { 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; } } function getUrl({ protocol, auth, hostname, host, repository }) { if (protocol === "ssh") 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 * */ async function pushCommitToRenovateRef(commitSha, refName) { 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 */ async function clearRenovateRefs() { if (!gitInitialized || !remoteRefsExist) return; logger.debug(`Cleaning up Renovate refs: refs/renovate/branches/*`); const renovateRefs = []; try { const refs = (await git.listRemote([config.url, "refs/renovate/branches/*"])).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(/^:(?<oldMode>\d{6})\s+(?<newMode>\d{6})\s+(?<oldSha>[0-9a-f]{40})\s+(?<newSha>[0-9a-f]{40})\s+(?<status>[A-Z]\d*)\t(?<paths>.+)$/); const treeShaRegex = regEx(/tree\s+(?<treeSha>[0-9a-f]{40})\s*/); /** * Get the tree SHA for a commit. */ async function getCommitTreeSha(commitSha) { const commitOutput = await git.catFile(["-p", commitSha]); const { treeSha } = 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 treeSha; } function treeTypeFromMode(mode) { 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). */ async function diffCommitTree(parentCommitSha, commitSha) { const output = await git.raw([ "diff-tree", "-M", "-r", "--no-commit-id", parentCommitSha, commitSha ]); const result = []; 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]; const [sourcePath, targetPath] = paths.split(" "); switch (statusCode) { case "D": result.push({ path: sourcePath, mode: oldMode, type: treeTypeFromMode(oldMode), sha: null }); break; case "R": result.push({ path: sourcePath, mode: oldMode, type: treeTypeFromMode(oldMode), sha: null }); result.push({ path: targetPath, mode: newMode, type: treeTypeFromMode(newMode), sha: newSha }); break; default: result.push({ path: targetPath ?? sourcePath, mode: newMode, type: treeTypeFromMode(newMode), sha: newSha }); break; } } } return result; } async function localBranchExists(branchName) { await syncGit(); return (await git.branchLocal()).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. */ async function syncForkWithUpstream(branchName) { if (!config.upstreamUrl) return; logger.debug(`Synchronizing fork with "${RENOVATE_FORK_UPSTREAM}" remote for branch ${branchName}`); /* v8 ignore if -- this should not be possible if upstreamUrl exists */ if (!(await getRemotes()).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); } } async function getRemotes() { 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 */ { logger.error({ err }, "Error getting remotes"); throw err; } } //#endregion export { GIT_MINIMUM_VERSION, RENOVATE_FORK_UPSTREAM, branchExists, checkoutBranch, checkoutBranchFromRemote, clearRenovateRefs, cloneSubmodules, commitFiles, createSimpleGit, deleteBranch, diffCommitTree, fetchBranch, fetchRevSpec, forcePushToRemote, getBranchCommit, getBranchFiles, getBranchFilesFromCommit, getBranchLastCommitTime, getBranchList, getBranchUpdateDate, getCommitMessages, getCommitTreeSha, getFile, getFileList, getFiles, getRemotes, getRepoStatus, getSubmodules, getUrl, gitRetry, hasDiff, initRepo, isBranchBehindBase, isBranchConflicted, isBranchModified, isCloned, mergeBranch, mergeToLocal, prepareCommit, pushCommit, pushCommitToRenovateRef, resetHardFromRemote, resetToCommit, setGitAuthor, setNoVerify, setPrivateKey, setUserRepoConfig, syncForkWithUpstream, syncGit, validateGitVersion, writeGitAuthor }; //# sourceMappingURL=index.js.map