diff --git a/lib/util/cache/repository/types.ts b/lib/util/cache/repository/types.ts index 5f1ce3679629d862a4b3dd66318807661cb83607..7ba2a0e3252ad16d5045ea6ec3ec21187cd50b9d 100644 --- a/lib/util/cache/repository/types.ts +++ b/lib/util/cache/repository/types.ts @@ -1,5 +1,6 @@ import type { PackageFile } from '../../../manager/types'; import type { RepoInitConfig } from '../../../workers/repository/init/types'; +import type { GitConflictsCache } from '../../git/types'; export interface BaseBranchCache { sha: string; // branch commit sha @@ -50,4 +51,5 @@ export interface Cache { graphqlPageCache?: Record<string, GithubGraphqlPageCache>; }; }; + gitConflicts?: GitConflictsCache; } diff --git a/lib/util/git/conflicts-cache.spec.ts b/lib/util/git/conflicts-cache.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..a6c8de8b80afe61a2239b264543a271181ab7b54 --- /dev/null +++ b/lib/util/git/conflicts-cache.spec.ts @@ -0,0 +1,141 @@ +import { mocked } from '../../../test/util'; +import * as _repositoryCache from '../../util/cache/repository'; +import type { Cache } from '../cache/repository/types'; +import { + getCachedConflictResult, + setCachedConflictResult, +} from './conflicts-cache'; + +jest.mock('../../util/cache/repository'); +const repositoryCache = mocked(_repositoryCache); + +describe('util/git/conflicts-cache', () => { + let repoCache: Cache = {}; + + beforeEach(() => { + repoCache = {}; + repositoryCache.getCache.mockReturnValue(repoCache); + }); + + describe('getCachedConflictResult', () => { + it('returns null if cache is not populated', () => { + expect(getCachedConflictResult('foo', '111', 'bar', '222')).toBeNull(); + }); + + it('returns null if target key not found', () => { + expect(getCachedConflictResult('foo', '111', 'bar', '222')).toBeNull(); + }); + + it('returns null if target SHA has changed', () => { + repoCache.gitConflicts = { + foo: { targetBranchSha: 'aaa', sourceBranches: {} }, + }; + expect(getCachedConflictResult('foo', '111', 'bar', '222')).toBeNull(); + }); + + it('returns null if source key not found', () => { + repoCache.gitConflicts = { + foo: { targetBranchSha: '111', sourceBranches: {} }, + }; + expect(getCachedConflictResult('foo', '111', 'bar', '222')).toBeNull(); + }); + + it('returns null if source key has changed', () => { + repoCache.gitConflicts = { + foo: { + targetBranchSha: '111', + sourceBranches: { + bar: { sourceBranchSha: 'bbb', isConflicted: true }, + }, + }, + }; + expect(getCachedConflictResult('foo', '111', 'bar', '222')).toBeNull(); + }); + + it('returns true', () => { + repoCache.gitConflicts = { + foo: { + targetBranchSha: '111', + sourceBranches: { + bar: { sourceBranchSha: '222', isConflicted: true }, + }, + }, + }; + expect(getCachedConflictResult('foo', '111', 'bar', '222')).toBeTrue(); + }); + + it('returns false', () => { + repoCache.gitConflicts = { + foo: { + targetBranchSha: '111', + sourceBranches: { + bar: { sourceBranchSha: '222', isConflicted: false }, + }, + }, + }; + expect(getCachedConflictResult('foo', '111', 'bar', '222')).toBeFalse(); + }); + }); + + describe('setCachedConflictResult', () => { + it('sets value for unpopulated cache', () => { + setCachedConflictResult('foo', '111', 'bar', '222', true); + expect(repoCache).toEqual({ + gitConflicts: { + foo: { + targetBranchSha: '111', + sourceBranches: { + bar: { sourceBranchSha: '222', isConflicted: true }, + }, + }, + }, + }); + }); + + it('replaces value when source SHA has changed', () => { + setCachedConflictResult('foo', '111', 'bar', '222', false); + setCachedConflictResult('foo', '111', 'bar', '333', false); + setCachedConflictResult('foo', '111', 'bar', '444', true); + expect(repoCache).toEqual({ + gitConflicts: { + foo: { + targetBranchSha: '111', + sourceBranches: { + bar: { sourceBranchSha: '444', isConflicted: true }, + }, + }, + }, + }); + }); + + it('replaces value when target SHA has changed', () => { + setCachedConflictResult('foo', '111', 'bar', '222', false); + setCachedConflictResult('foo', 'aaa', 'bar', '222', true); + expect(repoCache).toEqual({ + gitConflicts: { + foo: { + targetBranchSha: 'aaa', + sourceBranches: { + bar: { sourceBranchSha: '222', isConflicted: true }, + }, + }, + }, + }); + }); + + it('replaces value when both target and source SHA have changed', () => { + setCachedConflictResult('foo', '111', 'bar', '222', true); + setCachedConflictResult('foo', 'aaa', 'bar', 'bbb', false); + expect(repoCache).toEqual({ + gitConflicts: { + foo: { + targetBranchSha: 'aaa', + sourceBranches: { + bar: { sourceBranchSha: 'bbb', isConflicted: false }, + }, + }, + }, + }); + }); + }); +}); diff --git a/lib/util/git/conflicts-cache.ts b/lib/util/git/conflicts-cache.ts new file mode 100644 index 0000000000000000000000000000000000000000..35e56d63255e717a71120e64d5fea8214d8c3655 --- /dev/null +++ b/lib/util/git/conflicts-cache.ts @@ -0,0 +1,56 @@ +import { getCache } from '../cache/repository'; + +export function getCachedConflictResult( + targetBranchName: string, + targetBranchSha: string, + sourceBranchName: string, + sourceBranchSha: string +): boolean | null { + const { gitConflicts } = getCache(); + if (!gitConflicts) { + return null; + } + + const targetBranchConflicts = gitConflicts[targetBranchName]; + if (targetBranchConflicts?.targetBranchSha !== targetBranchSha) { + return null; + } + + const sourceBranchConflict = + targetBranchConflicts.sourceBranches[sourceBranchName]; + if (sourceBranchConflict?.sourceBranchSha !== sourceBranchSha) { + return null; + } + + return sourceBranchConflict.isConflicted; +} + +export function setCachedConflictResult( + targetBranchName: string, + targetBranchSha: string, + sourceBranchName: string, + sourceBranchSha: string, + isConflicted: boolean +): void { + const cache = getCache(); + cache.gitConflicts ??= {}; + const { gitConflicts } = cache; + + let targetBranchConflicts = gitConflicts[targetBranchName]; + if (targetBranchConflicts?.targetBranchSha !== targetBranchSha) { + gitConflicts[targetBranchName] = { + targetBranchSha, + sourceBranches: {}, + }; + targetBranchConflicts = gitConflicts[targetBranchName]; + } + + const sourceBranchConflict = + targetBranchConflicts.sourceBranches[sourceBranchName]; + if (sourceBranchConflict?.sourceBranchSha !== sourceBranchSha) { + targetBranchConflicts.sourceBranches[sourceBranchName] = { + sourceBranchSha, + isConflicted, + }; + } +} diff --git a/lib/util/git/index.spec.ts b/lib/util/git/index.spec.ts index 82168a7f0661bd1aef1f20f31f44c72771a20eec..7387019ab7ac911b9a29e1a12fb11822a1dfe649 100644 --- a/lib/util/git/index.spec.ts +++ b/lib/util/git/index.spec.ts @@ -1,12 +1,17 @@ import fs from 'fs-extra'; import Git from 'simple-git'; import tmp from 'tmp-promise'; +import { mocked } from '../../../test/util'; import { GlobalConfig } from '../../config/global'; import { CONFIG_VALIDATION } from '../../constants/error-messages'; +import * as _conflictsCache from './conflicts-cache'; import type { FileChange } from './types'; import * as git from '.'; import { setNoVerify } from '.'; +jest.mock('./conflicts-cache'); +const conflictsCache = mocked(_conflictsCache); + // Class is no longer exported const SimpleGit = Git().constructor as { prototype: ReturnType<typeof Git> }; @@ -631,6 +636,8 @@ describe('util/git/index', () => { await repo.commit('other (updated branch) message'); await repo.checkout(defaultBranch); + + conflictsCache.getCachedConflictResult.mockReturnValue(null); }); it('returns true for non-existing source branch', async () => { @@ -680,5 +687,70 @@ describe('util/git/index', () => { expect(status.current).toEqual(branchBefore); expect(status.isClean()).toBeTrue(); }); + + describe('cache', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + it('returns cached values', async () => { + conflictsCache.getCachedConflictResult.mockReturnValue(true); + + const res = await git.isBranchConflicted( + defaultBranch, + 'renovate/conflicted_branch' + ); + + expect(res).toBeTrue(); + expect(conflictsCache.getCachedConflictResult.mock.calls).toEqual([ + [ + defaultBranch, + expect.any(String), + 'renovate/conflicted_branch', + expect.any(String), + ], + ]); + expect(conflictsCache.setCachedConflictResult).not.toHaveBeenCalled(); + }); + + it('caches truthy return value', async () => { + conflictsCache.getCachedConflictResult.mockReturnValue(null); + + const res = await git.isBranchConflicted( + defaultBranch, + 'renovate/conflicted_branch' + ); + + expect(res).toBeTrue(); + expect(conflictsCache.setCachedConflictResult.mock.calls).toEqual([ + [ + defaultBranch, + expect.any(String), + 'renovate/conflicted_branch', + expect.any(String), + true, + ], + ]); + }); + + it('caches falsy return value', async () => { + conflictsCache.getCachedConflictResult.mockReturnValue(null); + + const res = await git.isBranchConflicted( + defaultBranch, + 'renovate/non_conflicted_branch' + ); + + expect(res).toBeFalse(); + expect(conflictsCache.setCachedConflictResult.mock.calls).toEqual([ + [ + defaultBranch, + expect.any(String), + 'renovate/non_conflicted_branch', + expect.any(String), + false, + ], + ]); + }); + }); }); }); diff --git a/lib/util/git/index.ts b/lib/util/git/index.ts index c019a3c5b27ba286db26c8f7d566a3cf74acacef..09ea3d52b8e746c56e9eed92be2fdf4058aebbc1 100644 --- a/lib/util/git/index.ts +++ b/lib/util/git/index.ts @@ -1,4 +1,5 @@ import URL from 'url'; +import is from '@sindresorhus/is'; import fs from 'fs-extra'; import simpleGit, { Options, @@ -26,6 +27,10 @@ import { Limit, incLimitedValue } from '../../workers/global/limits'; import { regEx } from '../regex'; import { parseGitAuthor } from './author'; import { getNoVerify, simpleGitConfig } from './config'; +import { + getCachedConflictResult, + setCachedConflictResult, +} from './conflicts-cache'; import { checkForPlatformFailure, handleCommitError } from './error'; import { configSigningKey, writePrivateKey } from './private-key'; import type { @@ -518,7 +523,10 @@ export async function isBranchConflicted( logger.debug(`isBranchConflicted(${baseBranch}, ${branch})`); await syncGit(); await writeGitAuthor(); - if (!branchExists(baseBranch) || !branchExists(branch)) { + + const baseBranchSha = getBranchCommit(baseBranch); + const branchSha = getBranchCommit(branch); + if (!baseBranchSha || !branchSha) { logger.warn( { baseBranch, branch }, 'isBranchConflicted: branch does not exist' @@ -526,6 +534,19 @@ export async function isBranchConflicted( return true; } + const cachedResult = getCachedConflictResult( + baseBranch, + baseBranchSha, + branch, + branchSha + ); + if (is.boolean(cachedResult)) { + logger.debug( + `Using cached result ${cachedResult} for isBranchConflicted(${baseBranch}, ${branch})` + ); + return cachedResult; + } + let result = false; const origBranch = config.currentBranch; @@ -558,6 +579,7 @@ export async function isBranchConflicted( } } + setCachedConflictResult(baseBranch, baseBranchSha, branch, branchSha, result); return result; } diff --git a/lib/util/git/types.ts b/lib/util/git/types.ts index d0f82cfcfda5edd0398baa6878594e05f8fe641c..c2e1519a669d19a80075ceeebabac08b78e92a1b 100644 --- a/lib/util/git/types.ts +++ b/lib/util/git/types.ts @@ -75,6 +75,22 @@ export interface CommitFilesConfig { force?: boolean; } +export type BranchName = string; +export type TargetBranchName = BranchName; +export type SourceBranchName = BranchName; + +export type GitConflictsCache = Record<TargetBranchName, TargetBranchConflicts>; + +export interface TargetBranchConflicts { + targetBranchSha: CommitSha; + sourceBranches: Record<SourceBranchName, SourceBranchConflict>; +} + +export interface SourceBranchConflict { + sourceBranchSha: CommitSha; + isConflicted: boolean; +} + export interface CommitResult { sha: string; files: FileChange[];