mirror of
https://github.com/renovatebot/renovate.git
synced 2026-10-27 07:50:47 +00:00
654431d69f
* test(util/git): skip the per-test clone for tests that need no repo The spec's `beforeEach` rebuilds a bare clone and calls `initRepo()` before every test, which costs about 0.7s each. Eight tests never touch a repository at all -- `createSimpleGit`, `gitRetry`, `validateGitVersion` and `getUrl` -- yet each paid for that clone. They now sit in a second root describe with no clone in it, so the file drops from about 129s to about 121s with the same 144 tests. The two blocks that are pure functions are marked `describe.concurrent`. The other two stay sequential: `createSimpleGit` spies on a prototype and sets the custom env, and `gitRetry` stubs `NODE_ENV`. This was the outcome of trying `it.concurrent` across the file. It cannot help there, for two reasons stacked on each other. Vitest still runs `beforeEach` for every concurrent test, so concurrency does not remove the clone, it only interleaves several of them. And those interleaved clones all write the same module singleton (`config`, `git`, `gitInitialized`) and `GlobalConfig.localDir`, so one test's `initRepo()` is overwritten by another's before its body runs. The only lever that works is not running the clone for tests that do not need it. Co-Authored-By: Claude Fable 5.1 <michael.kriese+claude-code@mend.io> * test(util/git): share one clone across the read-only tests The spec now has one root describe with three nested groups, split by how much git setup a test needs: - `without a repository`: pure functions, no setup at all. - `shared clone`: 32 read-only tests. One bare clone of `base` and one `initRepo()` in `beforeAll`, so each test costs only the git commands it runs itself. - `clone per test`: the tests that commit, push, check out or re-init. They keep the fresh clone and `initRepo()` per test. The `base` repository moves up to the root describe so both clone groups can use it. Groups run in file order, and some of the writing tests change `base` without restoring it, so the shared-clone group has to stay ahead. `isBranchModified()` stays with the writing tests although it never touches the repository: it caches per-branch results in `config.branchIsModified`, which only `initRepo()` clears, and its tests query the same branch with different ignored authors. Three describes are split by test: `getFileList`, `getBranchUpdateDate` and `diffCommitTree` each have one test that writes, which stays behind under the same describe name with a pointer to the read-only half. Same 144 tests; the file drops from about 121s to about 100s. Co-Authored-By: Claude Fable 5.1 <michael.kriese+claude-code@mend.io> * test(util/git): keep the 30s test timeout for every test The `{ timeout: 30000 }` option used to sit on the single root describe and so covered every test in the file. The split left it on the `clone per test` group only, which put the 32 shared-clone tests on the 5s default even though they still run real git commands. CI is a lot slower than a local machine, so the option moves up to the root describe again; nested describes inherit it. Hooks are unaffected either way: they run under `hookTimeout`, not the suite option, and the shared `beforeAll` does the same work the old `beforeEach` did. Co-Authored-By: Claude Fable 5.1 <michael.kriese+claude-code@mend.io> * test(util/git): reuse one clone for the local-only writers A fourth nested group, `reused clone`, holds the 36 tests that write to the local checkout but never to `origin` or `base`: `checkoutBranch`, `isBranchModified`, `mergeToLocal`, `isFileModeEnabled`, `getCommitMessages`, `setGitAuthor`, `getRepoStatus`, `fetchRevSpec` and single tests of `getBranchUpdateDate`, `mergeBranch`, `deleteBranch` and `diffCommitTree`. The group clones once, but `initRepo()` and `syncGit()` run again before every test. On an existing checkout `syncGit()` takes its fetch path: `fetch --prune`, `reset --hard`, `checkout` of the default branch, `clean -fd` and deletion of every other local branch. With the fresh `config` from `initRepo()` that undoes everything such a test can do to the repository or the module singleton, including the per-branch `branchIsModified` cache that kept `isBranchModified()` out of the shared-clone group. Local git config is the one thing it leaves alone, so the `beforeEach` re-sets the author that `setGitAuthor()` writes. That reset costs about 0.35s per test against about 0.87s for a clone, so the file drops from about 100s to about 87s. Same 144 tests. Co-Authored-By: Claude Fable 5.1 <michael.kriese+claude-code@mend.io> * test(util/git): trim the describe comments to the essentials Review feedback: the block comments read more like a narration of how the layout came to be than like documentation. Each now states only what a group's setup is, what a test may or may not do to belong there, and the ordering constraint about `base`. The explanation of the `isBranchModified()` cache is gone too, since that test now runs with a fresh `initRepo()` in the reused-clone group. Comments only, no test changes. Co-Authored-By: Claude Fable 5.1 <michael.kriese+claude-code@mend.io> * test(util/git): move isBranchModified() into the shared-clone group `isBranchModified()` only reads the repository, but it stores results in `config.branchIsModified`, which only `initRepo()` cleared. That kept its 16 tests in the reused-clone group at about 0.35s each. A new `clearBranchIsModifiedCache()` helper, marked `@internal`, clears that store. The shared-clone `beforeEach` calls it along with the cheap config setters (`setUserRepoConfig`, `setGitAuthor`, `setPlatformIgnoredAuthors`), so those tests now share one `initRepo()` and cost a few milliseconds each. The file drops from about 87s to about 83s. A dynamic import with `vi.resetModules()` was considered instead: a fresh module instance has no `config` and `gitInitialized = false`, so every test would need its own `initRepo()` and `syncGit()` on it, which is the reused-clone cost again plus re-importing the module graph. Co-Authored-By: Claude Fable 5.1 <michael.kriese+claude-code@mend.io> --------- Co-authored-by: Claude Fable 5.1 <michael.kriese+claude-code@mend.io>
1876 lines
56 KiB
TypeScript
1876 lines
56 KiB
TypeScript
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<SimpleGitOptions>;
|
|
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<T>(gitFunc: () => Promise<T>): Promise<T> {
|
|
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<boolean> {
|
|
try {
|
|
return (await fs.stat(dir)).isDirectory();
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function getDefaultBranch(git: InstrumentedSimpleGit): Promise<string> {
|
|
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<boolean> {
|
|
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<void> {
|
|
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<void> {
|
|
await gitRetry(() => git.fetch(['origin', ...revSpec]));
|
|
}
|
|
|
|
export async function initRepo(args: StorageConfig): Promise<void> {
|
|
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<void> {
|
|
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<void> {
|
|
logger.debug(`resetToCommit(${commit})`);
|
|
await git.raw(['reset', '--hard', commit]);
|
|
}
|
|
|
|
async function deleteLocalBranch(branchName: string): Promise<void> {
|
|
await git.branch(['-D', branchName]);
|
|
}
|
|
|
|
async function cleanLocalBranches(): Promise<void> {
|
|
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 <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, 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;
|
|
}
|
|
|
|
/**
|
|
* Clears the in-memory `isBranchModified()` results.
|
|
*
|
|
* @internal Test helper, so read-only tests can share one `initRepo()`.
|
|
*/
|
|
export function clearBranchIsModifiedCache(): void {
|
|
config.branchIsModified = {};
|
|
}
|
|
|
|
export async function getSubmodules(): Promise<string[]> {
|
|
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<void> {
|
|
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<boolean> {
|
|
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<void> => {
|
|
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<void> {
|
|
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<StatusResult> {
|
|
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<DateTime | null> {
|
|
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<string, DateTime>
|
|
> {
|
|
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<string, DateTime> = {};
|
|
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<string[]> {
|
|
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<LongCommitSha> {
|
|
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<LongCommitSha> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<string[]> {
|
|
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<boolean> {
|
|
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<boolean> {
|
|
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<string>();
|
|
try {
|
|
const commits = await git.log({
|
|
from: `origin/${baseBranch}`,
|
|
to: `origin/${branchName}`,
|
|
symmetric: false, // means <from>..<to> instead of <from>...<to>
|
|
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<string>();
|
|
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<boolean> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<DateTime> {
|
|
const output = await git.show(['-s', '--format=%cI', ref]);
|
|
return DateTime.fromISO(output.trim()).toUTC();
|
|
}
|
|
|
|
export async function getBranchLastCommitTime(
|
|
branchName: string,
|
|
): Promise<Date> {
|
|
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<string[] | null> {
|
|
return getBranchFilesFromRef(`origin/${branchName}`);
|
|
}
|
|
|
|
export function getBranchFilesFromCommit(
|
|
referenceCommit: LongCommitSha,
|
|
): Promise<string[] | null> {
|
|
return getBranchFilesFromRef(referenceCommit);
|
|
}
|
|
|
|
async function getBranchFilesFromRef(
|
|
refName: string,
|
|
): Promise<string[] | null> {
|
|
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<string | null> {
|
|
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<Record<string, string | null>> {
|
|
const fileContentMap: Record<string, string | null> = {};
|
|
|
|
for (const fileName of fileNames) {
|
|
fileContentMap[fileName] = await getFile(fileName);
|
|
}
|
|
|
|
return fileContentMap;
|
|
}
|
|
|
|
export async function hasDiff(
|
|
sourceRef: string,
|
|
targetRef: string,
|
|
): Promise<boolean> {
|
|
await syncGit();
|
|
try {
|
|
return (
|
|
(await gitRetry(() => git.diff([sourceRef, targetRef, '--']))) !== ''
|
|
);
|
|
} catch {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
async function handleCommitAuth(localDir: string): Promise<void> {
|
|
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<CommitResult | null> {
|
|
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<boolean> {
|
|
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<LongCommitSha | null> {
|
|
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<LongCommitSha | null> {
|
|
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<void> {
|
|
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<void> {
|
|
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(
|
|
/^:(?<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.
|
|
*/
|
|
export async function getCommitTreeSha(
|
|
commitSha: LongCommitSha,
|
|
): Promise<LongCommitSha> {
|
|
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<DiffTreeItem[]> {
|
|
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<boolean> {
|
|
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<void> {
|
|
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<string[]> {
|
|
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;
|
|
}
|
|
}
|