mirror of
https://github.com/renovatebot/renovate.git
synced 2026-10-27 07:50:47 +00:00
3b70d5ffe2
So we don't have tests exiting early when on i.e. a slower GitHub Actions machine.
2780 lines
96 KiB
TypeScript
2780 lines
96 KiB
TypeScript
import { isTruthy } from '@sindresorhus/is';
|
|
import { codeBlock } from 'common-tags';
|
|
import fs from 'fs-extra';
|
|
import { DateTime } from 'luxon';
|
|
import type { PushResult } from 'simple-git';
|
|
import { simpleGit } from 'simple-git';
|
|
import tmp from 'tmp-promise';
|
|
import { logger, partial } from '~test/util.ts';
|
|
import { GlobalConfig } from '../../config/global.ts';
|
|
import {
|
|
CONFIG_VALIDATION,
|
|
INVALID_PATH,
|
|
TEMPORARY_ERROR,
|
|
UNKNOWN_ERROR,
|
|
} from '../../constants/error-messages.ts';
|
|
import { postUpgradeCommandsExecutor } from '../../workers/repository/update/branch/execute-post-upgrade-commands.ts';
|
|
import type { BranchConfig, BranchUpgradeConfig } from '../../workers/types.ts';
|
|
import { setCustomEnv } from '../env.ts';
|
|
import * as _execCommon from '../exec/common.ts';
|
|
import { newlineRegex, regEx } from '../regex.ts';
|
|
import { type LongCommitSha, toLongCommitSha } from '../schema-utils/git.ts';
|
|
import * as _auth from './auth.ts';
|
|
import * as _behindBaseCache from './behind-base-branch-cache.ts';
|
|
import * as _conflictsCache from './conflicts-cache.ts';
|
|
import * as git from './index.ts';
|
|
import { setNoVerify } from './index.ts';
|
|
import * as _modifiedCache from './modified-cache.ts';
|
|
import type { FileChange } from './types.ts';
|
|
import * as _updateDateCache from './update-date-cache.ts';
|
|
|
|
vi.mock('./conflicts-cache.ts');
|
|
vi.mock('./behind-base-branch-cache.ts');
|
|
vi.mock('./modified-cache.ts');
|
|
vi.mock('./update-date-cache.ts');
|
|
vi.mock('timers/promises');
|
|
vi.mock('../cache/repository/index.ts');
|
|
vi.mock('./auth.ts');
|
|
vi.unmock('./index.ts');
|
|
|
|
const behindBaseCache = vi.mocked(_behindBaseCache);
|
|
const conflictsCache = vi.mocked(_conflictsCache);
|
|
const modifiedCache = vi.mocked(_modifiedCache);
|
|
const updateDateCache = vi.mocked(_updateDateCache);
|
|
const auth = vi.mocked(_auth);
|
|
const execCommon = vi.mocked(_execCommon);
|
|
// Class is no longer exported
|
|
const SimpleGit = simpleGit().constructor as {
|
|
prototype: ReturnType<typeof simpleGit>;
|
|
};
|
|
|
|
// Avoids flakiness when cleaning up temp directories
|
|
async function disableGitAutoMaintenance(
|
|
repo: ReturnType<typeof simpleGit>,
|
|
): Promise<void> {
|
|
await repo.addConfig('gc.auto', '0');
|
|
await repo.addConfig('maintenance.auto', 'false');
|
|
await repo.addConfig('receive.autogc', 'false');
|
|
}
|
|
|
|
// `base` is the repository the describes below clone from. They run in file
|
|
// order, and some tests in the last one write to `base`, so the describes that
|
|
// reuse a clone must stay ahead of it.
|
|
describe('util/git/index', { timeout: 30000 }, () => {
|
|
const masterCommitDate = new Date();
|
|
masterCommitDate.setMilliseconds(0);
|
|
let base: tmp.DirectoryResult;
|
|
let defaultBranch: string;
|
|
|
|
beforeAll(async () => {
|
|
base = await tmp.dir({ unsafeCleanup: true });
|
|
const repo = simpleGit(base.path);
|
|
await repo.init();
|
|
await disableGitAutoMaintenance(repo);
|
|
defaultBranch = (await repo.raw('branch', '--show-current')).trim();
|
|
await repo.addConfig('user.email', 'Jest@example.com');
|
|
await repo.addConfig('user.name', 'Jest');
|
|
await fs.writeFile(`${base.path}/past_file`, 'past');
|
|
await repo.addConfig('commit.gpgsign', 'false');
|
|
await repo.add(['past_file']);
|
|
await repo.commit('past message');
|
|
|
|
await repo.checkout(['-b', 'renovate/past_branch', defaultBranch]);
|
|
await repo.checkout(['-b', 'develop', defaultBranch]);
|
|
|
|
await repo.checkout(defaultBranch);
|
|
await fs.writeFile(`${base.path}/master_file`, defaultBranch);
|
|
await fs.writeFile(`${base.path}/file_to_delete`, 'bye');
|
|
await repo.add(['master_file', 'file_to_delete']);
|
|
vi.stubEnv('GIT_COMMITTER_DATE', masterCommitDate.toISOString());
|
|
await repo.commit('master message');
|
|
vi.stubEnv('GIT_COMMITTER_DATE', undefined);
|
|
|
|
await repo.checkout(['-b', 'renovate/future_branch', defaultBranch]);
|
|
await fs.writeFile(`${base.path}/future_file`, 'future');
|
|
await repo.add(['future_file']);
|
|
await repo.commit('future message');
|
|
|
|
await repo.checkoutBranch('renovate/modified_branch', defaultBranch);
|
|
await fs.writeFile(`${base.path}/base_file`, 'base');
|
|
await repo.add(['base_file']);
|
|
await repo.commit('base message');
|
|
await fs.writeFile(`${base.path}/modified_file`, 'modified');
|
|
await repo.add(['modified_file']);
|
|
await repo.commit('modification');
|
|
|
|
await repo.checkoutBranch('renovate/custom_author', defaultBranch);
|
|
await fs.writeFile(`${base.path}/custom_file`, 'custom');
|
|
await repo.add(['custom_file']);
|
|
await repo.addConfig('user.email', 'custom@example.com');
|
|
await repo.commit('custom message');
|
|
|
|
await repo.checkoutBranch('renovate/custom_author_brackets', defaultBranch);
|
|
await fs.writeFile(`${base.path}/custom_brackets_file`, 'custom');
|
|
await repo.add(['custom_brackets_file']);
|
|
await repo.addConfig(
|
|
'user.email',
|
|
'29139614+renovate[bot]@users.noreply.github.com',
|
|
);
|
|
await repo.commit('custom brackets message');
|
|
|
|
await repo.checkoutBranch('renovate/nested_files', defaultBranch);
|
|
await fs.mkdirp(`${base.path}/bin/`);
|
|
await fs.writeFile(`${base.path}/bin/nested`, 'nested');
|
|
await fs.writeFile(`${base.path}/root`, 'root');
|
|
await repo.add(['root', 'bin/nested']);
|
|
await repo.addConfig('user.email', 'custom@example.com');
|
|
await repo.commit('nested message');
|
|
|
|
await repo.checkout(['-b', 'renovate/hidden-unicode', defaultBranch]);
|
|
await fs.writeFile(`${base.path}/Dockerfile`, 'FROM scratch\u00A0');
|
|
await repo.add(['Dockerfile']);
|
|
await repo.commit('hidden Unicode');
|
|
|
|
await repo.checkout(['-b', 'renovate/binary-file', defaultBranch]);
|
|
const binaryContent = Buffer.from([
|
|
0x50, 0x4b, 0x03, 0x04, 0x00, 0x00, 0xe2, 0x80, 0x8b, 0x00,
|
|
]); // 0xe2 0x80 0x8b is UTF-8 encoding of U+200B (zero-width space)
|
|
await fs.writeFile(`${base.path}/binary.dat`, binaryContent);
|
|
await repo.add(['binary.dat']);
|
|
await repo.commit('add binary file');
|
|
|
|
await repo.checkoutBranch('renovate/equal_branch', defaultBranch);
|
|
|
|
await repo.checkoutBranch(
|
|
'renovate/branch_with_multiple_authors',
|
|
defaultBranch,
|
|
);
|
|
await repo.addConfig('user.email', 'author1@example.com');
|
|
await repo.commit('first commit', undefined, { '--allow-empty': null });
|
|
await repo.addConfig('user.email', 'author2@example.com');
|
|
await repo.commit('second commit', undefined, { '--allow-empty': null });
|
|
|
|
await repo.checkoutBranch('renovate/deeply/nested', defaultBranch);
|
|
|
|
// Renovate author, foreign committer (e.g. rebase/amend by someone else)
|
|
await repo.checkoutBranch('renovate/different_committer', defaultBranch);
|
|
await repo.addConfig('user.email', 'Jest@example.com');
|
|
await fs.writeFile(`${base.path}/committer_file`, 'test');
|
|
await repo.add(['committer_file']);
|
|
await repo
|
|
.env({ GIT_COMMITTER_EMAIL: 'someone-else@example.com' })
|
|
.commit('committed by someone else');
|
|
|
|
// Renovate author, GitHub platformCommit committer
|
|
await repo.checkoutBranch('renovate/platform_commit', defaultBranch);
|
|
await repo.addConfig('user.email', 'Jest@example.com');
|
|
await fs.writeFile(`${base.path}/platform_commit_file`, 'test');
|
|
await repo.add(['platform_commit_file']);
|
|
await repo
|
|
.env({ GIT_COMMITTER_EMAIL: 'noreply@github.com' })
|
|
.commit('platform api commit');
|
|
|
|
await repo.checkout(defaultBranch);
|
|
}, 30000);
|
|
|
|
afterAll(async () => {
|
|
setCustomEnv({});
|
|
await base?.cleanup();
|
|
});
|
|
|
|
// Tests that never touch a repository.
|
|
describe('without a repository', () => {
|
|
afterEach(() => {
|
|
setCustomEnv({});
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe('createSimpleGit()', () => {
|
|
it('adds authentication to the approved child environment', () => {
|
|
setCustomEnv({
|
|
GIT_CONFIG_COUNT: '1',
|
|
GIT_CONFIG_KEY_0: 'existing-key',
|
|
GIT_CONFIG_VALUE_0: 'existing-value',
|
|
});
|
|
const authenticatedEnv = {
|
|
GIT_CONFIG_COUNT: '4',
|
|
GIT_CONFIG_KEY_0: 'existing-key',
|
|
GIT_CONFIG_VALUE_0: 'existing-value',
|
|
};
|
|
auth.getGitEnvironmentVariables.mockReturnValue(authenticatedEnv);
|
|
const envSpy = vi.spyOn(SimpleGit.prototype, 'env');
|
|
|
|
git.createSimpleGit({
|
|
authentication: { hostTypes: ['git-refs'] },
|
|
});
|
|
|
|
expect(auth.getGitEnvironmentVariables).toHaveBeenCalledExactlyOnceWith(
|
|
expect.objectContaining({
|
|
GIT_CONFIG_COUNT: '1',
|
|
GIT_CONFIG_KEY_0: 'existing-key',
|
|
GIT_CONFIG_VALUE_0: 'existing-value',
|
|
}),
|
|
['git-refs'],
|
|
);
|
|
expect(envSpy).toHaveBeenCalledWith(authenticatedEnv);
|
|
});
|
|
});
|
|
|
|
// `gitRetry` reads `NODE_ENV`, and two of these stub it, so they stay
|
|
// sequential.
|
|
describe('gitRetry', () => {
|
|
it('returns result if git returns successfully', async () => {
|
|
const gitFunc = vi.fn().mockImplementation((args) => {
|
|
if (args === undefined) {
|
|
return 'some result';
|
|
}
|
|
return 'different result';
|
|
});
|
|
await expect(git.gitRetry(() => gitFunc())).resolves.toBe(
|
|
'some result',
|
|
);
|
|
await expect(git.gitRetry(() => gitFunc('arg'))).resolves.toBe(
|
|
'different result',
|
|
);
|
|
expect(gitFunc).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('retries the func call if ExternalHostError thrown', async () => {
|
|
vi.stubEnv('NODE_ENV', '');
|
|
const gitFunc = vi
|
|
.fn()
|
|
.mockImplementationOnce(() => {
|
|
throw new Error('The remote end hung up unexpectedly');
|
|
})
|
|
.mockImplementationOnce(() => {
|
|
throw new Error('The remote end hung up unexpectedly');
|
|
})
|
|
.mockImplementationOnce(() => 'some result');
|
|
await expect(git.gitRetry(() => gitFunc())).resolves.toBe(
|
|
'some result',
|
|
);
|
|
expect(gitFunc).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
it('retries the func call up to retry count if ExternalHostError thrown', async () => {
|
|
vi.stubEnv('NODE_ENV', '');
|
|
const gitFunc = vi.fn().mockImplementation(() => {
|
|
throw new Error('The remote end hung up unexpectedly');
|
|
});
|
|
await expect(git.gitRetry(() => gitFunc())).rejects.toThrow(
|
|
'The remote end hung up unexpectedly',
|
|
);
|
|
expect(gitFunc).toHaveBeenCalledTimes(6);
|
|
});
|
|
|
|
it("doesn't retry and throws an Error if non-ExternalHostError thrown by git", async () => {
|
|
const gitFunc = vi.fn().mockImplementationOnce(() => {
|
|
throw new Error('some error');
|
|
});
|
|
await expect(git.gitRetry(() => gitFunc())).rejects.toThrow(
|
|
'some error',
|
|
);
|
|
expect(gitFunc).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe.concurrent('validateGitVersion()', () => {
|
|
it('has a git version greater or equal to the minimum required', async () => {
|
|
const res = await git.validateGitVersion();
|
|
expect(res).toBeTrue();
|
|
});
|
|
});
|
|
|
|
describe.concurrent('Storage.getUrl()', () => {
|
|
const getUrl = git.getUrl;
|
|
|
|
it('returns https url', () => {
|
|
expect(
|
|
getUrl({
|
|
protocol: 'https',
|
|
auth: 'user:pass',
|
|
hostname: 'host',
|
|
repository: 'some/repo',
|
|
}),
|
|
).toBe('https://user:pass@host/some/repo.git');
|
|
expect(
|
|
getUrl({
|
|
auth: 'user:pass',
|
|
hostname: 'host',
|
|
repository: 'some/repo',
|
|
}),
|
|
).toBe('https://user:pass@host/some/repo.git');
|
|
});
|
|
|
|
it('returns ssh url', () => {
|
|
expect(
|
|
getUrl({
|
|
protocol: 'ssh',
|
|
auth: 'user:pass',
|
|
hostname: 'host',
|
|
repository: 'some/repo',
|
|
}),
|
|
).toBe('git@host:some/repo.git');
|
|
});
|
|
});
|
|
});
|
|
|
|
// Read-only tests: one clone and one `initRepo()` for the whole describe.
|
|
// The module config is reset before each test, so tests may set ignored
|
|
// authors, but must not change the clone, its refs or the working tree
|
|
// (`commitFiles`, `checkoutBranch`, `initRepo`, local git config).
|
|
describe('shared clone', () => {
|
|
let origin: tmp.DirectoryResult;
|
|
let tmpDir: tmp.DirectoryResult;
|
|
|
|
beforeAll(async () => {
|
|
setCustomEnv({});
|
|
origin = await tmp.dir({ unsafeCleanup: true });
|
|
const repo = simpleGit(origin.path);
|
|
await repo.clone(base.path, '.', ['--bare']);
|
|
await disableGitAutoMaintenance(repo);
|
|
await repo.addConfig('commit.gpgsign', 'false');
|
|
tmpDir = await tmp.dir({ unsafeCleanup: true });
|
|
GlobalConfig.set({ localDir: tmpDir.path });
|
|
await git.initRepo({ url: origin.path });
|
|
git.setUserRepoConfig({ branchPrefix: 'renovate/' });
|
|
git.setGitAuthor('Jest <Jest@example.com>');
|
|
git.setPlatformIgnoredAuthors([]);
|
|
setNoVerify([]);
|
|
await git.syncGit();
|
|
const local = simpleGit(tmpDir.path);
|
|
await local.addConfig('commit.gpgsign', 'false');
|
|
await local.addConfig('user.name', 'Jest');
|
|
await local.addConfig('user.email', 'Jest@example.com');
|
|
await disableGitAutoMaintenance(local);
|
|
});
|
|
|
|
beforeEach(() => {
|
|
git.setUserRepoConfig({ branchPrefix: 'renovate/' });
|
|
git.setGitAuthor('Jest <Jest@example.com>');
|
|
git.setPlatformIgnoredAuthors([]);
|
|
git.clearBranchIsModifiedCache();
|
|
behindBaseCache.getCachedBehindBaseResult.mockReturnValue(null);
|
|
updateDateCache.getCachedUpdateDateResult.mockReturnValue(null);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await tmpDir?.cleanup();
|
|
await origin?.cleanup();
|
|
});
|
|
|
|
describe('getFileList()', () => {
|
|
it('should return the correct files', async () => {
|
|
await expect(git.getFileList()).resolves.toEqual([
|
|
'file_to_delete',
|
|
'master_file',
|
|
'past_file',
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('branchExists(branchName)', () => {
|
|
it('should return true if found', () => {
|
|
expect(git.branchExists('renovate/future_branch')).toBeTrue();
|
|
});
|
|
|
|
it('should return false if not found', () => {
|
|
expect(git.branchExists('not_found')).toBeFalse();
|
|
});
|
|
});
|
|
|
|
describe('getBranchList()', () => {
|
|
it('should return all branches', () => {
|
|
const res = git.getBranchList();
|
|
expect(res).toContain('renovate/past_branch');
|
|
expect(res).toContain('renovate/future_branch');
|
|
expect(res).toContain(defaultBranch);
|
|
});
|
|
});
|
|
|
|
describe('isBranchBehindBase()', () => {
|
|
it('should return false if same SHA as master', async () => {
|
|
await expect(
|
|
git.isBranchBehindBase('renovate/future_branch', defaultBranch),
|
|
).resolves.toBeFalse();
|
|
});
|
|
|
|
it('should return true if SHA different from master', async () => {
|
|
await expect(
|
|
git.isBranchBehindBase('renovate/past_branch', defaultBranch),
|
|
).resolves.toBeTrue();
|
|
});
|
|
|
|
it('should return result even if non-default and not under branchPrefix', async () => {
|
|
await expect(
|
|
git.isBranchBehindBase('develop', defaultBranch),
|
|
).resolves.toBeTrue();
|
|
});
|
|
|
|
it('returns cached value', async () => {
|
|
behindBaseCache.getCachedBehindBaseResult.mockReturnValue(true);
|
|
await expect(
|
|
git.isBranchBehindBase('develop', defaultBranch),
|
|
).resolves.toBeTrue();
|
|
|
|
expect(logger.logger.debug).toHaveBeenCalledWith(
|
|
'branch.isBehindBase(): using cached result "true"',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('getBranchCommit(branchName)', () => {
|
|
it('should return same value for equal refs', () => {
|
|
const hex = git.getBranchCommit('renovate/equal_branch');
|
|
expect(hex).toBe(git.getBranchCommit(defaultBranch));
|
|
expect(hex).toHaveLength(40);
|
|
});
|
|
|
|
it('should return null', () => {
|
|
expect(git.getBranchCommit('not_found')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('isBranchModified()', () => {
|
|
beforeEach(() => {
|
|
modifiedCache.getCachedModifiedResult.mockReturnValue(null);
|
|
});
|
|
|
|
it('should return false when branch is not found', async () => {
|
|
await expect(
|
|
git.isBranchModified('renovate/not_found', defaultBranch),
|
|
).resolves.toBeFalse();
|
|
});
|
|
|
|
it('should return false when author matches', async () => {
|
|
await expect(
|
|
git.isBranchModified('renovate/future_branch', defaultBranch),
|
|
).resolves.toBeFalse();
|
|
await expect(
|
|
git.isBranchModified('renovate/future_branch', defaultBranch),
|
|
).resolves.toBeFalse();
|
|
});
|
|
|
|
it('should return false when author is ignored', async () => {
|
|
git.setUserRepoConfig({
|
|
gitIgnoredAuthors: ['custom@example.com'],
|
|
});
|
|
await expect(
|
|
git.isBranchModified('renovate/custom_author', defaultBranch),
|
|
).resolves.toBeFalse();
|
|
});
|
|
|
|
it('should return false when author matches ignored regex', async () => {
|
|
git.setUserRepoConfig({
|
|
gitIgnoredAuthors: ['/^custom@e.+\\.com$/'],
|
|
});
|
|
await expect(
|
|
git.isBranchModified('renovate/custom_author', defaultBranch),
|
|
).resolves.toBeFalse();
|
|
});
|
|
|
|
it('should return false when author matches ignored case-insensitive regex', async () => {
|
|
git.setUserRepoConfig({
|
|
gitIgnoredAuthors: ['/^CUSTOM@E.+\\.COM$/i'],
|
|
});
|
|
await expect(
|
|
git.isBranchModified('renovate/custom_author', defaultBranch),
|
|
).resolves.toBeFalse();
|
|
});
|
|
|
|
it('should return false when ignored author contains literal brackets', async () => {
|
|
git.setUserRepoConfig({
|
|
gitIgnoredAuthors: [
|
|
'29139614+renovate[bot]@users.noreply.github.com',
|
|
],
|
|
});
|
|
await expect(
|
|
git.isBranchModified(
|
|
'renovate/custom_author_brackets',
|
|
defaultBranch,
|
|
),
|
|
).resolves.toBeFalse();
|
|
});
|
|
|
|
it('should return false when author matches ignored regex with escaped brackets', async () => {
|
|
git.setUserRepoConfig({
|
|
gitIgnoredAuthors: [
|
|
'/renovate\\[bot\\]@users\\.noreply\\.github\\.com$/',
|
|
],
|
|
});
|
|
await expect(
|
|
git.isBranchModified(
|
|
'renovate/custom_author_brackets',
|
|
defaultBranch,
|
|
),
|
|
).resolves.toBeFalse();
|
|
});
|
|
|
|
it('should return true when author does not match ignored regex', async () => {
|
|
git.setUserRepoConfig({
|
|
gitIgnoredAuthors: ['/^other@example\\.com$/'],
|
|
});
|
|
await expect(
|
|
git.isBranchModified('renovate/custom_author', defaultBranch),
|
|
).resolves.toBeTrue();
|
|
});
|
|
|
|
it('should return false when author matches ignored glob pattern', async () => {
|
|
git.setUserRepoConfig({
|
|
gitIgnoredAuthors: ['custom@*'],
|
|
});
|
|
await expect(
|
|
git.isBranchModified('renovate/custom_author', defaultBranch),
|
|
).resolves.toBeFalse();
|
|
});
|
|
|
|
it('should return true when author does not match bracketed email pattern', async () => {
|
|
git.setUserRepoConfig({
|
|
gitIgnoredAuthors: [
|
|
'29139614+renovate[bxy]@users.noreply.github.com',
|
|
],
|
|
});
|
|
await expect(
|
|
git.isBranchModified(
|
|
'renovate/custom_author_brackets',
|
|
defaultBranch,
|
|
),
|
|
).resolves.toBeTrue();
|
|
});
|
|
|
|
it('should return true when non-ignored authors commit followed by an ignored author', async () => {
|
|
git.setUserRepoConfig({
|
|
gitIgnoredAuthors: ['author1@example.com'],
|
|
});
|
|
await expect(
|
|
git.isBranchModified(
|
|
'renovate/branch_with_multiple_authors',
|
|
defaultBranch,
|
|
),
|
|
).resolves.toBeTrue();
|
|
});
|
|
|
|
it('should return false with multiple authors that are each ignored', async () => {
|
|
git.setUserRepoConfig({
|
|
gitIgnoredAuthors: ['author1@example.com', 'author2@example.com'],
|
|
});
|
|
await expect(
|
|
git.isBranchModified(
|
|
'renovate/branch_with_multiple_authors',
|
|
defaultBranch,
|
|
),
|
|
).resolves.toBeFalse();
|
|
});
|
|
|
|
it('should return true when custom author is unknown', async () => {
|
|
await expect(
|
|
git.isBranchModified('renovate/custom_author', defaultBranch),
|
|
).resolves.toBeTrue();
|
|
});
|
|
|
|
it('should return true when committer is different from author', async () => {
|
|
await expect(
|
|
git.isBranchModified('renovate/different_committer', defaultBranch),
|
|
).resolves.toBeTrue();
|
|
});
|
|
|
|
it('should return false for ignored platformCommit committer', async () => {
|
|
git.setPlatformIgnoredAuthors(['noreply@github.com']);
|
|
await expect(
|
|
git.isBranchModified('renovate/platform_commit', defaultBranch),
|
|
).resolves.toBeFalse();
|
|
});
|
|
|
|
it('should return value stored in modifiedCacheResult', async () => {
|
|
modifiedCache.getCachedModifiedResult.mockReturnValue(true);
|
|
await expect(
|
|
git.isBranchModified('renovate/future_branch', defaultBranch),
|
|
).resolves.toBeTrue();
|
|
});
|
|
});
|
|
|
|
describe('getBranchUpdateDate(branchName)', () => {
|
|
it('should return null when branch does not exist', async () => {
|
|
await expect(git.getBranchUpdateDate('not_found')).resolves.toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('getAllBranchUpdateDates()', () => {
|
|
it('returns update dates for every remote branch', async () => {
|
|
const dates = await git.getAllBranchUpdateDates();
|
|
|
|
expect(dates['renovate/past_branch']).toBeInstanceOf(DateTime);
|
|
|
|
expect(dates[defaultBranch]).toBeInstanceOf(DateTime);
|
|
expect(dates[defaultBranch].toISO()).toEqual(
|
|
masterCommitDate.toISOString(),
|
|
);
|
|
});
|
|
|
|
it('returns the same date as getBranchUpdateDate for a given branch', async () => {
|
|
const dates = await git.getAllBranchUpdateDates();
|
|
|
|
const batchDate = dates['renovate/equal_branch'];
|
|
const singleDate = await git.getBranchUpdateDate(
|
|
'renovate/equal_branch',
|
|
);
|
|
|
|
expect(batchDate.toISO()).toBe(singleDate!.toISO());
|
|
});
|
|
|
|
it('excludes the origin/HEAD symbolic ref', async () => {
|
|
const dates = await git.getAllBranchUpdateDates();
|
|
|
|
expect(Object.keys(dates).sort()).toEqual([
|
|
'develop',
|
|
defaultBranch,
|
|
'renovate/binary-file',
|
|
'renovate/branch_with_multiple_authors',
|
|
'renovate/custom_author',
|
|
'renovate/custom_author_brackets',
|
|
'renovate/deeply/nested',
|
|
'renovate/different_committer',
|
|
'renovate/equal_branch',
|
|
'renovate/future_branch',
|
|
'renovate/hidden-unicode',
|
|
'renovate/modified_branch',
|
|
'renovate/nested_files',
|
|
'renovate/past_branch',
|
|
'renovate/platform_commit',
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('getBranchLastCommitTime', () => {
|
|
it('should return a Date', async () => {
|
|
const time = await git.getBranchLastCommitTime(defaultBranch);
|
|
expect(time).toEqual(masterCommitDate);
|
|
});
|
|
|
|
it('handles error', async () => {
|
|
const res = await git.getBranchLastCommitTime('some-branch');
|
|
expect(res).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe('getFile(filePath, branchName)', () => {
|
|
it('gets the file', async () => {
|
|
const res = await git.getFile('master_file');
|
|
expect(res).toBe(defaultBranch);
|
|
});
|
|
|
|
it('short cuts 404', async () => {
|
|
const res = await git.getFile('some-missing-path');
|
|
expect(res).toBeNull();
|
|
});
|
|
|
|
it('returns null for 404', async () => {
|
|
await expect(
|
|
git.getFile('some-path', 'some-branch'),
|
|
).resolves.toBeNull();
|
|
});
|
|
|
|
it('logs a warning if hidden Unciode characters are found', async () => {
|
|
await git.getFile('Dockerfile', 'renovate/hidden-unicode');
|
|
|
|
expect(logger.logger.once.warn).toHaveBeenCalledWith(
|
|
{ file: 'Dockerfile', hiddenCharacters: '\\u00A0' },
|
|
'Hidden Unicode characters have been discovered in file(s) in your repository. See your Renovate logs for more details. Please confirm that they are intended to be there, as they could be an attempt to "smuggle" text into your codebase, or used to confuse tools like Renovate or Large Language Models (LLMs)',
|
|
);
|
|
});
|
|
|
|
it('logs a trace message (not warning) if hidden Unicode characters are found in a binary file', async () => {
|
|
await git.getFile('binary.dat', 'renovate/binary-file');
|
|
|
|
expect(logger.logger.once.warn).toHaveBeenCalledTimes(0);
|
|
expect(logger.logger.trace).toHaveBeenCalledWith(
|
|
{
|
|
file: 'binary.dat',
|
|
hiddenCharacters: expect.stringContaining('\\u200B'),
|
|
},
|
|
'Hidden Unicode characters discovered in file `binary.dat`, but not logging higher than TRACE as it appears to be a binary file',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('getFiles(filePath)', () => {
|
|
it('gets the file', async () => {
|
|
const res = await git.getFiles(['master_file', 'some_missing_path']);
|
|
expect(res).toEqual({
|
|
master_file: defaultBranch,
|
|
some_missing_path: null,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('hasDiff(sourceRef, targetRef)', () => {
|
|
it('compare without changes', () => {
|
|
return expect(git.hasDiff('HEAD', 'HEAD')).resolves.toBeFalse();
|
|
});
|
|
|
|
it('compare with changes', () => {
|
|
return expect(
|
|
git.hasDiff('origin/master', 'origin/renovate/future_branch'),
|
|
).resolves.toBeTrue();
|
|
});
|
|
});
|
|
|
|
describe('getCommitTreeSha', () => {
|
|
it('returns the tree SHA for a commit', async () => {
|
|
const commit = git.getBranchCommit('develop')!;
|
|
const treeSha = await git.getCommitTreeSha(commit);
|
|
expect(treeSha).toBeString();
|
|
expect(treeSha).toHaveLength(40);
|
|
expect(treeSha).toMatch(regEx(/^[0-9a-f]{40}$/));
|
|
});
|
|
|
|
it('throws if commit output does not contain a tree SHA', async () => {
|
|
const commit = git.getBranchCommit('develop')!;
|
|
vi.spyOn(SimpleGit.prototype, 'catFile').mockResolvedValueOnce(
|
|
'parent deadbeef',
|
|
);
|
|
|
|
await expect(git.getCommitTreeSha(commit)).rejects.toThrow(
|
|
`Could not extract tree SHA from commit ${commit}: parent deadbeef`,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('diffCommitTree', () => {
|
|
it('returns changed files between two commits', async () => {
|
|
const parentCommit = git.getBranchCommit('develop')!;
|
|
const commit = git.getBranchCommit(defaultBranch)!;
|
|
const diff = await git.diffCommitTree(parentCommit, commit);
|
|
expect(diff.length).toBeGreaterThanOrEqual(2);
|
|
expect(diff).toContainEqual(
|
|
expect.objectContaining({
|
|
path: 'master_file',
|
|
mode: '100644',
|
|
type: 'blob',
|
|
}),
|
|
);
|
|
expect(diff).toContainEqual(
|
|
expect.objectContaining({ path: 'file_to_delete' }),
|
|
);
|
|
for (const item of diff) {
|
|
expect(item.sha).toMatch(regEx(/^[0-9a-f]{40}$/));
|
|
}
|
|
});
|
|
|
|
it('returns deletions with sha null', async () => {
|
|
const commit = git.getBranchCommit(defaultBranch)!;
|
|
const parentCommit = git.getBranchCommit('develop')!;
|
|
// Reverse: from default branch back to develop — master_file and file_to_delete are "deleted"
|
|
const diff = await git.diffCommitTree(commit, parentCommit);
|
|
expect(diff.length).toBeGreaterThanOrEqual(2);
|
|
const masterFile = diff.find((d) => d.path === 'master_file');
|
|
expect(masterFile?.sha).toBeNull();
|
|
const fileToDelete = diff.find((d) => d.path === 'file_to_delete');
|
|
expect(fileToDelete?.sha).toBeNull();
|
|
});
|
|
|
|
it('parses R status lines from diff-tree output', async () => {
|
|
const parentCommit = git.getBranchCommit('develop')!;
|
|
const commit = git.getBranchCommit(defaultBranch)!;
|
|
vi.spyOn(SimpleGit.prototype, 'raw').mockResolvedValueOnce(
|
|
':100644 100644 aaa0000000000000000000000000000000000000 bbb0000000000000000000000000000000000000 R100\told.txt\tnew.txt\n',
|
|
);
|
|
|
|
const diff = await git.diffCommitTree(parentCommit, commit);
|
|
|
|
expect(diff).toHaveLength(2);
|
|
expect(diff).toContainEqual({
|
|
path: 'old.txt',
|
|
mode: '100644',
|
|
type: 'blob',
|
|
sha: null,
|
|
});
|
|
expect(diff).toContainEqual({
|
|
path: 'new.txt',
|
|
mode: '100644',
|
|
type: 'blob',
|
|
sha: 'bbb0000000000000000000000000000000000000',
|
|
});
|
|
});
|
|
|
|
it('maps mode 160000 to type commit for submodules', async () => {
|
|
const parentCommit = git.getBranchCommit('develop')!;
|
|
const commit = git.getBranchCommit(defaultBranch)!;
|
|
vi.spyOn(SimpleGit.prototype, 'raw').mockResolvedValueOnce(
|
|
':000000 160000 0000000000000000000000000000000000000000 abc0000000000000000000000000000000000000 A\tvendor/sub\n',
|
|
);
|
|
|
|
const diff = await git.diffCommitTree(parentCommit, commit);
|
|
|
|
expect(diff).toContainEqual({
|
|
path: 'vendor/sub',
|
|
mode: '160000',
|
|
type: 'commit',
|
|
sha: 'abc0000000000000000000000000000000000000',
|
|
});
|
|
});
|
|
|
|
it('maps mode 040000 to type tree', async () => {
|
|
const parentCommit = git.getBranchCommit('develop')!;
|
|
const commit = git.getBranchCommit(defaultBranch)!;
|
|
vi.spyOn(SimpleGit.prototype, 'raw').mockResolvedValueOnce(
|
|
':000000 040000 0000000000000000000000000000000000000000 def0000000000000000000000000000000000000 A\tsome/dir\n',
|
|
);
|
|
|
|
const diff = await git.diffCommitTree(parentCommit, commit);
|
|
|
|
expect(diff).toContainEqual({
|
|
path: 'some/dir',
|
|
mode: '040000',
|
|
type: 'tree',
|
|
sha: 'def0000000000000000000000000000000000000',
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('getSubmodules', () => {
|
|
it('should return empty array', async () => {
|
|
await expect(git.getSubmodules()).resolves.toHaveLength(0);
|
|
});
|
|
});
|
|
});
|
|
|
|
// Local-only writers: one clone, but `initRepo()` and `syncGit()` run again
|
|
// before every test. On an existing checkout `syncGit()` fetches, resets to
|
|
// the default branch, cleans the working tree and deletes other local
|
|
// branches, and `initRepo()` resets the module state. Tests here may change
|
|
// the local checkout but must not push to `origin`, call `initRepo()` with
|
|
// their own options or touch `base`.
|
|
describe('reused clone', () => {
|
|
let origin: tmp.DirectoryResult;
|
|
let tmpDir: tmp.DirectoryResult;
|
|
|
|
beforeAll(async () => {
|
|
origin = await tmp.dir({ unsafeCleanup: true });
|
|
const repo = simpleGit(origin.path);
|
|
await repo.clone(base.path, '.', ['--bare']);
|
|
await disableGitAutoMaintenance(repo);
|
|
await repo.addConfig('commit.gpgsign', 'false');
|
|
tmpDir = await tmp.dir({ unsafeCleanup: true });
|
|
GlobalConfig.set({ localDir: tmpDir.path });
|
|
await git.initRepo({ url: origin.path });
|
|
await git.syncGit();
|
|
const local = simpleGit(tmpDir.path);
|
|
await local.addConfig('commit.gpgsign', 'false');
|
|
await disableGitAutoMaintenance(local);
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
setCustomEnv({});
|
|
GlobalConfig.set({ localDir: tmpDir.path });
|
|
await git.initRepo({ url: origin.path });
|
|
git.setUserRepoConfig({ branchPrefix: 'renovate/' });
|
|
git.setGitAuthor('Jest <Jest@example.com>');
|
|
git.setPlatformIgnoredAuthors([]);
|
|
setNoVerify([]);
|
|
await git.syncGit();
|
|
// `syncGit()` leaves local git config alone, and `writeGitAuthor()` may
|
|
// have changed the author in the previous test
|
|
const local = simpleGit(tmpDir.path);
|
|
await local.addConfig('user.name', 'Jest');
|
|
await local.addConfig('user.email', 'Jest@example.com');
|
|
behindBaseCache.getCachedBehindBaseResult.mockReturnValue(null);
|
|
updateDateCache.getCachedUpdateDateResult.mockReturnValue(null);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await tmpDir?.cleanup();
|
|
await origin?.cleanup();
|
|
});
|
|
|
|
describe('checkoutBranch(branchName)', () => {
|
|
it('sets the base branch as master', async () => {
|
|
await expect(git.checkoutBranch(defaultBranch)).resolves.not.toThrow();
|
|
});
|
|
|
|
it('sets non-master base branch', async () => {
|
|
await expect(git.checkoutBranch('develop')).resolves.not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('getBranchUpdateDate(branchName)', () => {
|
|
it('should return same value for equal refs', async () => {
|
|
await git.checkoutBranchFromRemote('renovate/equal_branch', 'origin');
|
|
await git.fetchBranch(defaultBranch);
|
|
const date = await git.getBranchUpdateDate('renovate/equal_branch');
|
|
const defaultDate = await git.getBranchUpdateDate(defaultBranch);
|
|
expect(date!.toISO()).toBe(defaultDate!.toISO());
|
|
expect(date).toBeInstanceOf(DateTime);
|
|
expect(updateDateCache.setCachedUpdateDateResult).toHaveBeenCalledWith(
|
|
'renovate/equal_branch',
|
|
expect.any(DateTime),
|
|
);
|
|
});
|
|
|
|
it('returns cached result without syncing git when cache is populated', async () => {
|
|
const branchName = 'renovate/equal_branch';
|
|
const cachedDate = DateTime.fromISO('2023-05-20T14:25:30.123Z');
|
|
updateDateCache.getCachedUpdateDateResult.mockReturnValueOnce(
|
|
cachedDate,
|
|
);
|
|
await git.checkoutBranchFromRemote(branchName, 'origin');
|
|
const result = await git.getBranchUpdateDate(branchName);
|
|
expect(result).toBe(cachedDate);
|
|
expect(
|
|
updateDateCache.setCachedUpdateDateResult,
|
|
).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('mergeBranch(branchName)', () => {
|
|
it('should throw if branch merge throws', async () => {
|
|
await expect(git.mergeBranch('not_found')).rejects.toThrow(
|
|
"fatal: 'origin/not_found' is not a commit and a branch 'not_found'",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('mergeToLocal(branchName)', () => {
|
|
it('should perform a branch merge without push', async () => {
|
|
await expect(
|
|
fs.pathExists(`${tmpDir.path}/future_file`),
|
|
).resolves.toBeFalse();
|
|
const pushSpy = vi.spyOn(SimpleGit.prototype, 'push');
|
|
|
|
await git.mergeToLocal('renovate/future_branch');
|
|
|
|
await expect(
|
|
fs.pathExists(`${tmpDir.path}/future_file`),
|
|
).resolves.toBeTrue();
|
|
expect(pushSpy).toHaveBeenCalledTimes(0);
|
|
});
|
|
|
|
it('should merge a local-only virtual branch without fetching from origin', async () => {
|
|
// Create a local-only branch (never pushed to origin)
|
|
const commit = await git.prepareCommit({
|
|
branchName: 'renovate/local_only_branch',
|
|
message: 'local only commit',
|
|
files: [
|
|
{ type: 'addition', path: 'local_only_file', contents: 'local' },
|
|
],
|
|
});
|
|
await git.setVirtualBranch(
|
|
'renovate/local_only_branch',
|
|
'refs/changes/99/99999/1',
|
|
commit!.commitSha,
|
|
);
|
|
// Reset working tree back to default branch so the file is not present yet
|
|
const local = simpleGit(tmpDir.path);
|
|
await local.checkout(defaultBranch);
|
|
|
|
await expect(
|
|
fs.pathExists(`${tmpDir.path}/local_only_file`),
|
|
).resolves.toBeFalse();
|
|
const fetchSpy = vi.spyOn(SimpleGit.prototype, 'fetch');
|
|
const pushSpy = vi.spyOn(SimpleGit.prototype, 'push');
|
|
|
|
await git.mergeToLocal('renovate/local_only_branch');
|
|
|
|
await expect(
|
|
fs.pathExists(`${tmpDir.path}/local_only_file`),
|
|
).resolves.toBeTrue();
|
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
expect(pushSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should throw', async () => {
|
|
await expect(git.mergeToLocal('not_found')).rejects.toThrow(
|
|
"fatal: couldn't find remote ref not_found",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('deleteBranch(branchName)', () => {
|
|
it('should only delete local branch for a virtual branch', async () => {
|
|
const sha = git.getBranchCommit('renovate/past_branch')!;
|
|
await git.setVirtualBranch(
|
|
'renovate/past_branch',
|
|
'refs/changes/99/99999/1',
|
|
sha,
|
|
);
|
|
const rawSpy = vi.spyOn(SimpleGit.prototype, 'raw');
|
|
await git.deleteBranch('renovate/past_branch');
|
|
expect(rawSpy).not.toHaveBeenCalledWith(
|
|
expect.arrayContaining(['push', '--delete']),
|
|
);
|
|
expect(rawSpy).toHaveBeenCalledWith([
|
|
'update-ref',
|
|
'-d',
|
|
'refs/remotes/origin/renovate/past_branch',
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('isFileModeEnabled()', () => {
|
|
it('defaults to enabled when core.fileMode is unset', async () => {
|
|
const repo = simpleGit(tmpDir.path);
|
|
await repo.raw(['config', '--unset', 'core.fileMode']);
|
|
|
|
await expect(git.isFileModeEnabled()).resolves.toBeTrue();
|
|
});
|
|
|
|
it.each([
|
|
{ setting: 'true', expected: true },
|
|
{ setting: 'false', expected: false },
|
|
])(
|
|
'returns $expected when core.fileMode is $setting',
|
|
async ({ setting, expected }) => {
|
|
const repo = simpleGit(tmpDir.path);
|
|
await repo.addConfig('core.fileMode', setting);
|
|
|
|
await expect(git.isFileModeEnabled()).resolves.toBe(expected);
|
|
},
|
|
);
|
|
|
|
it('caches a disabled setting for the repository run', async () => {
|
|
const repo = simpleGit(tmpDir.path);
|
|
await repo.addConfig('core.fileMode', 'false');
|
|
await expect(git.isFileModeEnabled()).resolves.toBeFalse();
|
|
|
|
await repo.addConfig('core.fileMode', 'true');
|
|
await expect(git.isFileModeEnabled()).resolves.toBeFalse();
|
|
});
|
|
});
|
|
|
|
describe('getCommitMessages()', () => {
|
|
it('returns commit messages without merge commits', async () => {
|
|
const repo = simpleGit(tmpDir.path);
|
|
await repo.merge(['--no-ff', 'origin/renovate/future_branch']);
|
|
expect((await git.getCommitMessages()).sort()).toEqual([
|
|
'future message',
|
|
'master message',
|
|
'past message',
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('setGitAuthor()', () => {
|
|
it('throws for invalid', () => {
|
|
expect(() => git.setGitAuthor('invalid')).toThrow(CONFIG_VALIDATION);
|
|
});
|
|
|
|
it('defaults to "Renovate" when undefined', async () => {
|
|
git.setGitAuthor(undefined);
|
|
await git.writeGitAuthor();
|
|
const local = simpleGit(tmpDir.path);
|
|
expect((await local.raw(['config', 'user.name'])).trim()).toBe(
|
|
'Renovate',
|
|
);
|
|
expect((await local.raw(['config', 'user.email'])).trim()).toBe(
|
|
'renovate@whitesourcesoftware.com',
|
|
);
|
|
});
|
|
});
|
|
|
|
// The read-only `diffCommitTree` tests are in the shared-clone describe above.
|
|
describe('diffCommitTree', () => {
|
|
it('returns renames as deletion and addition entries', async () => {
|
|
const repo = simpleGit(tmpDir.path);
|
|
await repo.addConfig('user.email', 'Jest@example.com');
|
|
await repo.addConfig('user.name', 'Jest');
|
|
const parentCommit = git.getBranchCommit(defaultBranch)!;
|
|
|
|
await repo.raw(['mv', 'master_file', 'renamed_master_file']);
|
|
await repo.commit('rename master file');
|
|
|
|
const commit = toLongCommitSha((await repo.revparse(['HEAD'])).trim());
|
|
const diff = await git.diffCommitTree(parentCommit, commit);
|
|
|
|
expect(diff).toHaveLength(2);
|
|
expect(diff).toContainEqual({
|
|
path: 'master_file',
|
|
mode: '100644',
|
|
type: 'blob',
|
|
sha: null,
|
|
});
|
|
expect(diff).toContainEqual(
|
|
expect.objectContaining({
|
|
path: 'renamed_master_file',
|
|
mode: '100644',
|
|
type: 'blob',
|
|
sha: expect.stringMatching(/^[0-9a-f]{40}$/),
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('getRepoStatus', () => {
|
|
it('should pass options into git status', async () => {
|
|
await git.checkoutBranch('renovate/nested_files');
|
|
|
|
await fs.writeFile(`${tmpDir.path}/bin/nested`, 'new nested');
|
|
await fs.writeFile(`${tmpDir.path}/root`, 'new root');
|
|
const resp = await git.getRepoStatus('bin');
|
|
|
|
expect(resp.modified).toStrictEqual(['bin/nested']);
|
|
});
|
|
|
|
it('should reject when trying to access directory out of localDir', async () => {
|
|
GlobalConfig.set({ localDir: tmpDir.path });
|
|
await git.checkoutBranch('renovate/nested_files');
|
|
|
|
await fs.writeFile(`${tmpDir.path}/bin/nested`, 'new nested');
|
|
await fs.writeFile(`${tmpDir.path}/root`, 'new root');
|
|
|
|
await expect(git.getRepoStatus('../../bin')).rejects.toThrow(
|
|
INVALID_PATH,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('fetchRevSpec()', () => {
|
|
it('fetchRevSpec()', async () => {
|
|
await git.fetchRevSpec(
|
|
`refs/heads/${defaultBranch}:refs/heads/other/${defaultBranch}`,
|
|
);
|
|
//checkout this duplicate
|
|
const sha = await git.checkoutBranch(`other/${defaultBranch}`);
|
|
expect(sha).toBe(git.getBranchCommit(defaultBranch));
|
|
});
|
|
});
|
|
});
|
|
|
|
// Tests that push to `origin`, write to `base` or call `initRepo()` with
|
|
// their own options. Each gets a fresh clone and `initRepo()`.
|
|
describe('clone per test', () => {
|
|
let origin: tmp.DirectoryResult;
|
|
let tmpDir: tmp.DirectoryResult;
|
|
|
|
beforeEach(async () => {
|
|
setCustomEnv({});
|
|
origin = await tmp.dir({ unsafeCleanup: true });
|
|
const repo = simpleGit(origin.path);
|
|
await repo.clone(base.path, '.', ['--bare']);
|
|
await disableGitAutoMaintenance(repo);
|
|
await repo.addConfig('commit.gpgsign', 'false');
|
|
tmpDir = await tmp.dir({ unsafeCleanup: true });
|
|
GlobalConfig.set({ localDir: tmpDir.path });
|
|
await git.initRepo({
|
|
url: origin.path,
|
|
});
|
|
git.setUserRepoConfig({ branchPrefix: 'renovate/' });
|
|
git.setGitAuthor('Jest <Jest@example.com>');
|
|
git.setPlatformIgnoredAuthors([]);
|
|
setNoVerify([]);
|
|
await git.syncGit();
|
|
// override some local git settings for better testing
|
|
const local = simpleGit(tmpDir.path);
|
|
await local.addConfig('commit.gpgsign', 'false');
|
|
await local.addConfig('user.name', 'Jest');
|
|
await local.addConfig('user.email', 'Jest@example.com');
|
|
await disableGitAutoMaintenance(local);
|
|
behindBaseCache.getCachedBehindBaseResult.mockReturnValue(null);
|
|
updateDateCache.getCachedUpdateDateResult.mockReturnValue(null);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await tmpDir?.cleanup();
|
|
await origin?.cleanup();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
// The local-only `checkoutBranch()` tests are in the reused-clone describe above.
|
|
describe('checkoutBranch(branchName)', () => {
|
|
describe('submodules', () => {
|
|
beforeEach(async () => {
|
|
const repo = simpleGit(base.path);
|
|
|
|
auth.getGitEnvironmentVariables.mockReturnValue({
|
|
GIT_ALLOW_PROTOCOL: 'file',
|
|
});
|
|
|
|
const submoduleBasePath = `${base.path}/submodule`;
|
|
await fs.mkdir(submoduleBasePath);
|
|
const submodule = simpleGit(submoduleBasePath);
|
|
await submodule.init();
|
|
await disableGitAutoMaintenance(submodule);
|
|
await submodule.addConfig('user.email', 'Jest@example.com');
|
|
await submodule.addConfig('user.name', 'Jest');
|
|
await submodule.addConfig('commit.gpgsign', 'false');
|
|
|
|
await fs.writeFile(`${submoduleBasePath}/init_file`, 'init');
|
|
await submodule.add('init_file');
|
|
await submodule.commit('init submodule');
|
|
|
|
await repo.submoduleAdd('./submodule', './submodule');
|
|
await repo.commit('add submodule');
|
|
await repo.branch(['stable']);
|
|
|
|
await fs.writeFile(`${submoduleBasePath}/current_file`, 'current');
|
|
await submodule.add('current_file');
|
|
await submodule.commit('update');
|
|
await repo.add('submodule');
|
|
await repo.commit('update submodule');
|
|
});
|
|
|
|
it('verifies that the --recurse-submodule flag is needed', async () => {
|
|
const repo = simpleGit(base.path);
|
|
expect((await repo.status()).isClean()).toBeTrue();
|
|
await repo.checkout('stable');
|
|
expect((await repo.status()).isClean()).toBeFalse();
|
|
});
|
|
|
|
it('sets non-master base branch with submodule update', async () => {
|
|
await git.initRepo({
|
|
cloneSubmodules: true,
|
|
url: base.path,
|
|
});
|
|
expect((await git.getRepoStatus()).isClean()).toBeTrue();
|
|
await git.checkoutBranch('stable');
|
|
expect((await git.getRepoStatus()).isClean()).toBeTrue();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
const repo = simpleGit(base.path);
|
|
const defaultBranch =
|
|
(await repo.getConfig('init.defaultbranch')).value ?? 'master';
|
|
await repo.checkout(defaultBranch);
|
|
await repo.reset(['--hard', 'HEAD~2']);
|
|
await repo.branch(['-D', 'stable']);
|
|
await fs.rm(`${base.path}/submodule`, { recursive: true });
|
|
});
|
|
});
|
|
});
|
|
|
|
// The read-only `getFileList()` test is in the shared-clone describe above.
|
|
describe('getFileList()', () => {
|
|
it('should exclude submodules', async () => {
|
|
const repo = simpleGit(base.path);
|
|
await repo.submoduleAdd(base.path, 'submodule');
|
|
await repo.submoduleAdd(base.path, 'file');
|
|
await repo.commit('Add submodules');
|
|
await git.initRepo({
|
|
cloneSubmodules: true,
|
|
cloneSubmodulesFilter: ['file'],
|
|
url: base.path,
|
|
});
|
|
expect(git.isCloned()).toBeFalse();
|
|
await git.syncGit();
|
|
await expect(
|
|
fs.pathExists(`${tmpDir.path}/.gitmodules`),
|
|
).resolves.toBeTruthy();
|
|
await expect(git.getFileList()).resolves.toEqual([
|
|
'.gitmodules',
|
|
'file_to_delete',
|
|
'master_file',
|
|
'past_file',
|
|
]);
|
|
await repo.reset(['--hard', 'HEAD^']);
|
|
});
|
|
});
|
|
|
|
// The read-only `isBranchModified()` tests are in the shared-clone describe above.
|
|
describe('isBranchModified()', () => {
|
|
beforeEach(() => {
|
|
modifiedCache.getCachedModifiedResult.mockReturnValue(null);
|
|
});
|
|
|
|
it('should not be affected by new commits on the base branch', async () => {
|
|
// Add a commit to the base branch from a different author,
|
|
// causing the branches to diverge
|
|
const local = simpleGit(tmpDir.path);
|
|
await local.checkout(defaultBranch);
|
|
await local.addConfig('user.email', 'other-dev@example.com');
|
|
await fs.writeFile(`${tmpDir.path}/diverge_file`, 'diverge');
|
|
await local.add(['diverge_file']);
|
|
await local.commit('diverge commit');
|
|
await local.push('origin', defaultBranch);
|
|
// Re-init so Renovate picks up the new origin state
|
|
await git.initRepo({ url: origin.path });
|
|
git.setGitAuthor('Jest <Jest@example.com>');
|
|
await git.syncGit();
|
|
|
|
await expect(
|
|
git.isBranchModified('renovate/future_branch', defaultBranch),
|
|
).resolves.toBeFalse();
|
|
});
|
|
});
|
|
|
|
// The read-only and local-only `getBranchUpdateDate()` tests are in the
|
|
// describes above.
|
|
describe('getBranchUpdateDate(branchName)', () => {
|
|
it('should return null and log error when git show fails', async () => {
|
|
// Create a valid branch first
|
|
const branchName = 'renovate/test_error_branch';
|
|
await git.commitFiles({
|
|
branchName,
|
|
files: [
|
|
{
|
|
type: 'addition',
|
|
path: 'error-test-file',
|
|
contents: 'test content',
|
|
},
|
|
],
|
|
message: 'Test commit',
|
|
});
|
|
|
|
// Spy on DateTime.fromISO to simulate an error in getCommitDate
|
|
const fromISOSpy = vi
|
|
.spyOn(DateTime, 'fromISO')
|
|
.mockImplementationOnce(() => {
|
|
throw new Error('simulated git show error');
|
|
});
|
|
|
|
const result = await git.getBranchUpdateDate(branchName);
|
|
|
|
expect(result).toBeNull();
|
|
expect(fromISOSpy).toHaveBeenCalled();
|
|
|
|
// Restore original implementation
|
|
fromISOSpy.mockRestore();
|
|
});
|
|
|
|
it('works if running with a Repo Cache', async () => {
|
|
// Create a branch with a commit so it exists in the remote
|
|
const branchName = 'renovate/cache-test-branch';
|
|
await git.commitFiles({
|
|
branchName,
|
|
files: [
|
|
{
|
|
type: 'addition',
|
|
path: 'cache-test-file',
|
|
contents: 'test content',
|
|
},
|
|
],
|
|
message: 'Test commit',
|
|
});
|
|
|
|
// Simulate a new run of Renovate, when running from a fresh localDir, where we've not already cloned anything, as happens when using RENOVATE_REPOSITORY_CACHE=enabled
|
|
const freshDir = await tmp.dir({ unsafeCleanup: true });
|
|
try {
|
|
GlobalConfig.set({ localDir: freshDir.path });
|
|
await git.initRepo({ url: origin.path });
|
|
git.setUserRepoConfig({ branchPrefix: 'renovate/' });
|
|
git.setGitAuthor('Jest <Jest@example.com>');
|
|
setNoVerify([]);
|
|
|
|
// will sync Git
|
|
const result = await git.getBranchUpdateDate(branchName);
|
|
|
|
expect(result).toBeInstanceOf(DateTime);
|
|
expect(result).not.toBeNull();
|
|
} finally {
|
|
await freshDir.cleanup();
|
|
GlobalConfig.set({ localDir: tmpDir.path });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('getBranchFiles(branchName)', () => {
|
|
it('detects changed files compared to current base branch', async () => {
|
|
const file: FileChange = {
|
|
type: 'addition',
|
|
path: 'some-new-file',
|
|
contents: 'some new-contents',
|
|
};
|
|
await git.commitFiles({
|
|
branchName: 'renovate/branch_with_changes',
|
|
files: [
|
|
file,
|
|
{ type: 'addition', path: 'dummy', contents: null as never },
|
|
],
|
|
message: 'Create something',
|
|
});
|
|
const branchFiles = await git.getBranchFiles(
|
|
'renovate/branch_with_changes',
|
|
);
|
|
expect(branchFiles).toEqual(['some-new-file']);
|
|
});
|
|
});
|
|
|
|
describe('getBranchFilesFromCommit(sha)', () => {
|
|
it('detects changed files compared to the parent commit', async () => {
|
|
const file: FileChange = {
|
|
type: 'addition',
|
|
path: 'some-new-file',
|
|
contents: 'some new-contents',
|
|
};
|
|
const sha = await git.commitFiles({
|
|
branchName: 'renovate/branch_with_changes',
|
|
files: [
|
|
file,
|
|
{ type: 'addition', path: 'dummy', contents: null as never },
|
|
],
|
|
message: 'Create something',
|
|
});
|
|
const branchFiles = await git.getBranchFilesFromCommit(sha!);
|
|
expect(branchFiles).toEqual(['some-new-file']);
|
|
});
|
|
});
|
|
|
|
// The local-only `mergeBranch()` tests are in the reused-clone describe above.
|
|
describe('mergeBranch(branchName)', () => {
|
|
it('should perform a branch merge', async () => {
|
|
await git.mergeBranch('renovate/future_branch');
|
|
const merged = await simpleGit(origin.path).branch([
|
|
'--verbose',
|
|
'--merged',
|
|
defaultBranch,
|
|
]);
|
|
expect(merged.all).toContain('renovate/future_branch');
|
|
});
|
|
});
|
|
|
|
// The local-only `deleteBranch()` tests are in the reused-clone describe above.
|
|
describe('deleteBranch(branchName)', () => {
|
|
it('should send delete', async () => {
|
|
await git.deleteBranch('renovate/past_branch');
|
|
const branches = await simpleGit(origin.path).branch({});
|
|
expect(branches.all).not.toContain('renovate/past_branch');
|
|
});
|
|
|
|
it('should add no verify flag', async () => {
|
|
const rawSpy = vi.spyOn(SimpleGit.prototype, 'raw');
|
|
await git.deleteBranch('renovate/something');
|
|
expect(rawSpy).toHaveBeenCalledExactlyOnceWith([
|
|
'push',
|
|
'--delete',
|
|
'origin',
|
|
'renovate/something',
|
|
]);
|
|
});
|
|
|
|
it('should not add no verify flag', async () => {
|
|
const rawSpy = vi.spyOn(SimpleGit.prototype, 'raw');
|
|
setNoVerify(['push']);
|
|
await git.deleteBranch('renovate/something');
|
|
expect(rawSpy).toHaveBeenCalledExactlyOnceWith([
|
|
'push',
|
|
'--delete',
|
|
'origin',
|
|
'renovate/something',
|
|
'--no-verify',
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('commitFiles({branchName, files, message})', () => {
|
|
it('creates file', async () => {
|
|
const file: FileChange = {
|
|
type: 'addition',
|
|
path: 'some-new-file',
|
|
contents: 'some new-contents',
|
|
};
|
|
const commit = await git.commitFiles({
|
|
branchName: 'renovate/past_branch',
|
|
files: [file],
|
|
message: 'Create something',
|
|
});
|
|
expect(commit).not.toBeNull();
|
|
});
|
|
|
|
it('link file', async () => {
|
|
const file: FileChange = {
|
|
type: 'addition',
|
|
path: 'future_link',
|
|
contents: 'past_file',
|
|
isSymlink: true,
|
|
};
|
|
const commit = await git.commitFiles({
|
|
branchName: 'renovate/future_branch',
|
|
files: [file],
|
|
message: 'Create a link',
|
|
});
|
|
expect(commit).toBeString();
|
|
const tmpGit = simpleGit(tmpDir.path);
|
|
const lsTree = await tmpGit.raw(['ls-tree', commit!]);
|
|
const files = lsTree
|
|
.trim()
|
|
.split(newlineRegex)
|
|
.map((x) => x.split(regEx(/\s/)))
|
|
.map(([mode, type, _hash, name]) => [mode, type, name]);
|
|
expect(files).toContainEqual(['100644', 'blob', 'past_file']);
|
|
expect(files).toContainEqual(['120000', 'blob', 'future_link']);
|
|
});
|
|
|
|
it('adds trailers as the final block of the commit message', async () => {
|
|
const file: FileChange = {
|
|
type: 'addition',
|
|
path: 'some-trailer-file',
|
|
contents: 'some new-contents',
|
|
};
|
|
|
|
const commit = await git.commitFiles({
|
|
branchName: 'renovate/branch_with_trailers',
|
|
files: [file],
|
|
message: ['Update something', 'Some commit body'],
|
|
trailers: [
|
|
'Signed-off-by: Renovate Bot <bot@renovateapp.com>',
|
|
'Co-authored-by: First Contributor <first@example.com>',
|
|
'Co-authored-by: Second Contributor <second@example.com>',
|
|
],
|
|
});
|
|
|
|
expect(commit).not.toBeNull();
|
|
const tmpGit = simpleGit(tmpDir.path);
|
|
const parsedTrailers = await tmpGit.raw([
|
|
'log',
|
|
'-1',
|
|
'--format=%(trailers:only)',
|
|
commit!,
|
|
]);
|
|
expect(parsedTrailers.trim()).toBe(codeBlock`
|
|
Signed-off-by: Renovate Bot <bot@renovateapp.com>
|
|
Co-authored-by: First Contributor <first@example.com>
|
|
Co-authored-by: Second Contributor <second@example.com>
|
|
`);
|
|
});
|
|
|
|
it('adds trailers when commit message is a string', async () => {
|
|
const file: FileChange = {
|
|
type: 'addition',
|
|
path: 'some-string-trailer-file',
|
|
contents: 'some new-contents',
|
|
};
|
|
|
|
const commit = await git.commitFiles({
|
|
branchName: 'renovate/branch_with_string_message_trailers',
|
|
files: [file],
|
|
message: 'Update something',
|
|
trailers: ['Signed-off-by: Renovate Bot <bot@renovateapp.com>'],
|
|
});
|
|
|
|
expect(commit).not.toBeNull();
|
|
const tmpGit = simpleGit(tmpDir.path);
|
|
const parsedTrailers = await tmpGit.raw([
|
|
'log',
|
|
'-1',
|
|
'--format=%(trailers:only)',
|
|
commit!,
|
|
]);
|
|
expect(parsedTrailers.trim()).toBe(
|
|
'Signed-off-by: Renovate Bot <bot@renovateapp.com>',
|
|
);
|
|
});
|
|
|
|
it('deletes file', async () => {
|
|
const file: FileChange = {
|
|
type: 'deletion',
|
|
path: 'file_to_delete',
|
|
};
|
|
const commit = await git.commitFiles({
|
|
branchName: 'renovate/something',
|
|
files: [file],
|
|
message: 'Delete something',
|
|
});
|
|
expect(commit).not.toBeNull();
|
|
});
|
|
|
|
it('updates multiple files', async () => {
|
|
const files: FileChange[] = [
|
|
{
|
|
type: 'addition',
|
|
path: 'some-existing-file',
|
|
contents: 'updated content',
|
|
},
|
|
{
|
|
type: 'addition',
|
|
path: 'some-other-existing-file',
|
|
contents: 'other updated content',
|
|
},
|
|
];
|
|
const commit = await git.commitFiles({
|
|
branchName: 'renovate/something',
|
|
files,
|
|
message: 'Update something',
|
|
});
|
|
expect(commit).not.toBeNull();
|
|
});
|
|
|
|
it('uses right commit SHA', async () => {
|
|
const files: FileChange[] = [
|
|
{
|
|
type: 'addition',
|
|
path: 'some-existing-file',
|
|
contents: 'updated content',
|
|
},
|
|
{
|
|
type: 'addition',
|
|
path: 'some-other-existing-file',
|
|
contents: 'other updated content',
|
|
},
|
|
];
|
|
const commitConfig = {
|
|
baseBranch: 'renovate/something',
|
|
branchName: 'renovate/something',
|
|
files,
|
|
message: 'Update something',
|
|
};
|
|
const commitSha = await git.commitFiles(commitConfig);
|
|
const remoteSha = await git.fetchBranch(commitConfig.branchName);
|
|
expect(commitSha).toEqual(remoteSha);
|
|
});
|
|
|
|
it('updates git submodules', async () => {
|
|
const files: FileChange[] = [
|
|
{
|
|
type: 'addition',
|
|
path: '.',
|
|
contents: 'some content',
|
|
},
|
|
];
|
|
const commit = await git.commitFiles({
|
|
branchName: 'renovate/something',
|
|
files,
|
|
message: 'Update something',
|
|
});
|
|
expect(commit).toBeNull();
|
|
});
|
|
|
|
it('does not push when no diff', async () => {
|
|
const files: FileChange[] = [
|
|
{
|
|
type: 'addition',
|
|
path: 'future_file',
|
|
contents: 'future',
|
|
},
|
|
];
|
|
const commit = await git.commitFiles({
|
|
branchName: 'renovate/future_branch',
|
|
files,
|
|
message: 'No change update',
|
|
});
|
|
expect(commit).toBeNull();
|
|
});
|
|
|
|
it('does not pass --no-verify', async () => {
|
|
const commitSpy = vi.spyOn(SimpleGit.prototype, 'commit');
|
|
const pushSpy = vi.spyOn(SimpleGit.prototype, 'push');
|
|
|
|
const files: FileChange[] = [
|
|
{
|
|
type: 'addition',
|
|
path: 'some-new-file',
|
|
contents: 'some new-contents',
|
|
},
|
|
];
|
|
|
|
await git.commitFiles({
|
|
branchName: 'renovate/something',
|
|
files,
|
|
message: 'Pass no-verify',
|
|
});
|
|
|
|
expect(commitSpy).toHaveBeenCalledExactlyOnceWith(
|
|
expect.anything(),
|
|
expect.anything(),
|
|
expect.not.objectContaining({ '--no-verify': null }),
|
|
);
|
|
expect(pushSpy).toHaveBeenCalledExactlyOnceWith(
|
|
expect.anything(),
|
|
expect.anything(),
|
|
expect.not.objectContaining({ '--no-verify': null }),
|
|
);
|
|
});
|
|
|
|
it('passes --no-verify to commit', async () => {
|
|
const commitSpy = vi.spyOn(SimpleGit.prototype, 'commit');
|
|
const pushSpy = vi.spyOn(SimpleGit.prototype, 'push');
|
|
|
|
const files: FileChange[] = [
|
|
{
|
|
type: 'addition',
|
|
path: 'some-new-file',
|
|
contents: 'some new-contents',
|
|
},
|
|
];
|
|
setNoVerify(['commit']);
|
|
|
|
await git.commitFiles({
|
|
branchName: 'renovate/something',
|
|
files,
|
|
message: 'Pass no-verify',
|
|
});
|
|
|
|
expect(commitSpy).toHaveBeenCalledExactlyOnceWith(
|
|
expect.anything(),
|
|
expect.anything(),
|
|
expect.objectContaining({ '--no-verify': null }),
|
|
);
|
|
expect(pushSpy).toHaveBeenCalledExactlyOnceWith(
|
|
expect.anything(),
|
|
expect.anything(),
|
|
expect.not.objectContaining({ '--no-verify': null }),
|
|
);
|
|
});
|
|
|
|
it('passes --no-verify to push', async () => {
|
|
const commitSpy = vi.spyOn(SimpleGit.prototype, 'commit');
|
|
const pushSpy = vi.spyOn(SimpleGit.prototype, 'push');
|
|
|
|
const files: FileChange[] = [
|
|
{
|
|
type: 'addition',
|
|
path: 'some-new-file',
|
|
contents: 'some new-contents',
|
|
},
|
|
];
|
|
setNoVerify(['push']);
|
|
|
|
await git.commitFiles({
|
|
branchName: 'renovate/something',
|
|
files,
|
|
message: 'Pass no-verify',
|
|
});
|
|
|
|
expect(commitSpy).toHaveBeenCalledExactlyOnceWith(
|
|
expect.anything(),
|
|
expect.anything(),
|
|
expect.not.objectContaining({ '--no-verify': null }),
|
|
);
|
|
expect(pushSpy).toHaveBeenCalledExactlyOnceWith(
|
|
expect.anything(),
|
|
expect.anything(),
|
|
expect.objectContaining({ '--no-verify': null }),
|
|
);
|
|
});
|
|
|
|
it('creates file with the executable bit', async () => {
|
|
const file: FileChange = {
|
|
type: 'addition',
|
|
path: 'some-executable',
|
|
contents: 'some new-contents',
|
|
isExecutable: true,
|
|
};
|
|
const commit = await git.commitFiles({
|
|
branchName: 'renovate/past_branch',
|
|
files: [file],
|
|
message: 'Create something',
|
|
});
|
|
expect(commit).not.toBeNull();
|
|
|
|
const repo = simpleGit(tmpDir.path);
|
|
const result = await repo.raw(['ls-tree', 'HEAD', 'some-executable']);
|
|
expect(result).toStartWith('100755');
|
|
});
|
|
|
|
it('preserves a post-upgrade chmod in the committed tree', async ({
|
|
skip,
|
|
}) => {
|
|
const local = simpleGit(tmpDir.path);
|
|
const fileMode = await local.getConfig('core.fileMode');
|
|
skip(
|
|
fileMode.value !== 'true',
|
|
'Git does not track executable bits in this working tree',
|
|
);
|
|
|
|
await fs.chmod(`${tmpDir.path}/master_file`, 0o644);
|
|
auth.getGitEnvironmentVariables.mockReturnValue({});
|
|
execCommon.rawExec.mockImplementationOnce(async (command, options) => {
|
|
expect(command).toBe('chmod +x master_file');
|
|
expect(options.cwd).toBe(tmpDir.path);
|
|
await fs.chmod(`${tmpDir.path}/master_file`, 0o755);
|
|
return { stdout: '', stderr: '' };
|
|
});
|
|
GlobalConfig.set({
|
|
localDir: tmpDir.path,
|
|
allowedCommands: ['^chmod \\+x master_file$'],
|
|
});
|
|
|
|
const commands = partial<BranchUpgradeConfig>([
|
|
{
|
|
manager: 'some-manager',
|
|
branchName: 'renovate/executable-post-upgrade',
|
|
postUpgradeTasks: {
|
|
commands: ['chmod +x master_file'],
|
|
fileFilters: ['master_file'],
|
|
executionMode: 'branch',
|
|
},
|
|
},
|
|
]);
|
|
const config: BranchConfig = {
|
|
manager: 'some-manager',
|
|
updatedPackageFiles: [],
|
|
updatedArtifacts: [],
|
|
upgrades: [],
|
|
branchName: 'renovate/executable-post-upgrade',
|
|
baseBranch: defaultBranch,
|
|
};
|
|
|
|
const { updatedArtifacts } = await postUpgradeCommandsExecutor(
|
|
commands,
|
|
config,
|
|
);
|
|
expect(updatedArtifacts).toEqual([
|
|
{
|
|
type: 'addition',
|
|
path: 'master_file',
|
|
contents: Buffer.from(defaultBranch),
|
|
isExecutable: true,
|
|
},
|
|
]);
|
|
|
|
const commit = await git.prepareCommit({
|
|
branchName: 'renovate/executable-post-upgrade',
|
|
files: updatedArtifacts,
|
|
message: 'Preserve executable mode',
|
|
});
|
|
expect(commit).not.toBeNull();
|
|
|
|
const result = await local.raw(['ls-tree', 'HEAD', 'master_file']);
|
|
expect(result).toStartWith('100755');
|
|
});
|
|
});
|
|
|
|
describe('initRepo())', () => {
|
|
it('should fetch latest', async () => {
|
|
const repo = simpleGit(base.path);
|
|
await repo.checkout(['-b', 'test', defaultBranch]);
|
|
await fs.writeFile(`${base.path}/test`, 'lorem ipsum');
|
|
await repo.add(['test']);
|
|
await repo.commit('past message2');
|
|
await repo.checkout(defaultBranch);
|
|
|
|
expect(git.branchExists('test')).toBeFalsy();
|
|
|
|
await expect(git.getCommitMessages()).resolves.toEqual([
|
|
'master message',
|
|
'past message',
|
|
]);
|
|
|
|
await git.checkoutBranch('develop');
|
|
|
|
await git.initRepo({
|
|
url: base.path,
|
|
});
|
|
|
|
expect(git.branchExists('test')).toBeTruthy();
|
|
|
|
await git.checkoutBranch('test');
|
|
|
|
const msg = await git.getCommitMessages();
|
|
expect(msg).toEqual([
|
|
'past message2',
|
|
'master message',
|
|
'past message',
|
|
]);
|
|
expect(msg).toContain('past message2');
|
|
});
|
|
|
|
it('should set branch prefix', async () => {
|
|
const repo = simpleGit(base.path);
|
|
await repo.checkout(['-b', 'renovate/test', defaultBranch]);
|
|
await fs.writeFile(`${base.path}/test`, 'lorem ipsum');
|
|
await repo.add(['test']);
|
|
await repo.commit('past message2');
|
|
await repo.checkout(defaultBranch);
|
|
|
|
await git.initRepo({
|
|
url: base.path,
|
|
});
|
|
|
|
git.setUserRepoConfig({ branchPrefix: 'renovate/' });
|
|
expect(git.branchExists('renovate/test')).toBeTrue();
|
|
|
|
await git.initRepo({
|
|
url: base.path,
|
|
});
|
|
|
|
await repo.checkout('renovate/test');
|
|
await repo.commit('past message3', ['--amend']);
|
|
|
|
git.setUserRepoConfig({ branchPrefix: 'renovate/' });
|
|
expect(git.branchExists('renovate/test')).toBeTrue();
|
|
});
|
|
|
|
it('should fail clone ssh submodule', async () => {
|
|
const repo = simpleGit(base.path);
|
|
await fs.writeFile(
|
|
`${base.path}/.gitmodules`,
|
|
'[submodule "test"]\npath=test\nurl=ssh://0.0.0.0',
|
|
);
|
|
await repo.add('.gitmodules');
|
|
await repo.raw([
|
|
'update-index',
|
|
'--add',
|
|
'--cacheinfo',
|
|
'160000',
|
|
'4b825dc642cb6eb9a060e54bf8d69288fbee4904',
|
|
'test',
|
|
]);
|
|
await repo.commit('Add submodule');
|
|
await git.initRepo({
|
|
cloneSubmodules: true,
|
|
url: base.path,
|
|
});
|
|
await git.syncGit();
|
|
await expect(
|
|
fs.pathExists(`${tmpDir.path}/.gitmodules`),
|
|
).resolves.toBeTruthy();
|
|
await repo.reset(['--hard', 'HEAD^']);
|
|
});
|
|
|
|
it('should use extra clone configuration', async () => {
|
|
await fs.emptyDir(tmpDir.path);
|
|
await git.initRepo({
|
|
url: origin.path,
|
|
extraCloneOpts: {
|
|
'-c': 'extra.clone.config=test-extra-config-value',
|
|
},
|
|
fullClone: true,
|
|
});
|
|
git.getBranchCommit(defaultBranch);
|
|
await git.syncGit();
|
|
const repo = simpleGit(tmpDir.path);
|
|
const res = (await repo.raw(['config', 'extra.clone.config'])).trim();
|
|
expect(res).toBe('test-extra-config-value');
|
|
});
|
|
|
|
it('should not pass extraCloneOpts to ls-remote when local repo exists', async () => {
|
|
const extraCloneOpts = {
|
|
'-c': 'extra.clone.config=test-extra-config-value',
|
|
};
|
|
|
|
await fs.emptyDir(tmpDir.path);
|
|
await git.initRepo({
|
|
url: origin.path,
|
|
extraCloneOpts,
|
|
fullClone: true,
|
|
});
|
|
await git.syncGit();
|
|
|
|
const rawSpy = vi.spyOn(SimpleGit.prototype, 'raw');
|
|
|
|
await git.initRepo({
|
|
url: origin.path,
|
|
extraCloneOpts,
|
|
fullClone: true,
|
|
});
|
|
|
|
expect(rawSpy).toHaveBeenCalledWith([
|
|
'ls-remote',
|
|
'--heads',
|
|
origin.path,
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('isBranchConflicted', () => {
|
|
beforeAll(async () => {
|
|
const repo = simpleGit(base.path);
|
|
await repo.init();
|
|
|
|
await repo.checkout([
|
|
'-b',
|
|
'renovate/conflicted_branch',
|
|
defaultBranch,
|
|
]);
|
|
await repo.checkout([
|
|
'-b',
|
|
'renovate/non_conflicted_branch',
|
|
defaultBranch,
|
|
]);
|
|
|
|
await repo.checkout(defaultBranch);
|
|
await fs.writeFile(`${base.path}/one_file`, 'past (updated)');
|
|
await repo.add(['one_file']);
|
|
await repo.commit('past (updated) message');
|
|
|
|
await repo.checkout('renovate/conflicted_branch');
|
|
await fs.writeFile(`${base.path}/one_file`, 'past (updated branch)');
|
|
await repo.add(['one_file']);
|
|
await repo.commit('past (updated branch) message');
|
|
|
|
await repo.checkout('renovate/non_conflicted_branch');
|
|
await fs.writeFile(`${base.path}/another_file`, 'other');
|
|
await repo.add(['another_file']);
|
|
await repo.commit('other (updated branch) message');
|
|
|
|
await repo.checkout(defaultBranch);
|
|
|
|
conflictsCache.getCachedConflictResult.mockReturnValue(null);
|
|
});
|
|
|
|
it('returns true for non-existing source branch', async () => {
|
|
const res = await git.isBranchConflicted(
|
|
defaultBranch,
|
|
'renovate/non_existing_branch',
|
|
);
|
|
expect(res).toBeTrue();
|
|
});
|
|
|
|
it('returns true for non-existing target branch', async () => {
|
|
const res = await git.isBranchConflicted(
|
|
'renovate/non_existing_branch',
|
|
'renovate/non_conflicted_branch',
|
|
);
|
|
expect(res).toBeTrue();
|
|
});
|
|
|
|
it('detects conflicted branch', async () => {
|
|
const branchBefore = 'renovate/non_conflicted_branch';
|
|
await git.checkoutBranch(branchBefore);
|
|
|
|
const res = await git.isBranchConflicted(
|
|
defaultBranch,
|
|
'renovate/conflicted_branch',
|
|
);
|
|
|
|
expect(res).toBeTrue();
|
|
|
|
const status = await git.getRepoStatus();
|
|
expect(status.current).toEqual(branchBefore);
|
|
expect(status.isClean()).toBeTrue();
|
|
});
|
|
|
|
it('detects non-conflicted branch', async () => {
|
|
const branchBefore = 'renovate/conflicted_branch';
|
|
await git.checkoutBranch(branchBefore);
|
|
|
|
const res = await git.isBranchConflicted(
|
|
defaultBranch,
|
|
'renovate/non_conflicted_branch',
|
|
);
|
|
|
|
expect(res).toBeFalse();
|
|
|
|
const status = await git.getRepoStatus();
|
|
expect(status.current).toEqual(branchBefore);
|
|
expect(status.isClean()).toBeTrue();
|
|
});
|
|
|
|
describe('cachedConflictResult', () => {
|
|
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([
|
|
[
|
|
'renovate/conflicted_branch',
|
|
git.getBranchCommit('renovate/conflicted_branch'),
|
|
defaultBranch,
|
|
git.getBranchCommit(defaultBranch),
|
|
],
|
|
]);
|
|
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([
|
|
['renovate/conflicted_branch', 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([
|
|
['renovate/non_conflicted_branch', false],
|
|
]);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Renovate non-branch refs', () => {
|
|
async function lsRenovateRefs(): Promise<string[]> {
|
|
return (
|
|
await simpleGit(tmpDir.path).raw([
|
|
'ls-remote',
|
|
'origin',
|
|
'refs/renovate/*',
|
|
])
|
|
)
|
|
.split(newlineRegex)
|
|
.map((line) => line.replace(regEx(/[0-9a-f]+\s+/i), ''))
|
|
.filter(isTruthy);
|
|
}
|
|
|
|
it('creates renovate ref in default section', async () => {
|
|
const commit = git.getBranchCommit('develop')!;
|
|
|
|
await git.pushCommitToRenovateRef(commit, 'foo/bar');
|
|
|
|
const renovateRefs = await lsRenovateRefs();
|
|
expect(renovateRefs).toContain('refs/renovate/branches/foo/bar');
|
|
});
|
|
|
|
it('creates custom section for renovate ref', async () => {
|
|
const commit = git.getBranchCommit('develop')!;
|
|
|
|
await git.pushCommitToRenovateRef(commit, 'bar/baz');
|
|
|
|
const renovateRefs = await lsRenovateRefs();
|
|
expect(renovateRefs).toContain('refs/renovate/branches/bar/baz');
|
|
});
|
|
|
|
it('clears pushed Renovate refs', async () => {
|
|
const commit = git.getBranchCommit('develop')!;
|
|
await git.pushCommitToRenovateRef(commit, 'foo');
|
|
await git.pushCommitToRenovateRef(commit, 'bar');
|
|
await git.pushCommitToRenovateRef(commit, 'baz');
|
|
|
|
await expect(lsRenovateRefs()).resolves.not.toBeEmpty();
|
|
await git.clearRenovateRefs();
|
|
await expect(lsRenovateRefs()).resolves.toBeEmpty();
|
|
});
|
|
|
|
it('clears remote Renovate refs', async () => {
|
|
const commit = git.getBranchCommit('develop')!;
|
|
const tmpGit = simpleGit(tmpDir.path);
|
|
await tmpGit.raw(['update-ref', 'refs/renovate/branches/aaa', commit]);
|
|
await tmpGit.raw([
|
|
'push',
|
|
'--force',
|
|
'origin',
|
|
'refs/renovate/branches/aaa',
|
|
]);
|
|
|
|
await git.pushCommitToRenovateRef(commit, 'bbb');
|
|
await git.pushCommitToRenovateRef(commit, 'ccc');
|
|
|
|
const pushSpy = vi.spyOn(SimpleGit.prototype, 'push');
|
|
|
|
await expect(lsRenovateRefs()).resolves.not.toBeEmpty();
|
|
await git.clearRenovateRefs();
|
|
await expect(lsRenovateRefs()).resolves.toBeEmpty();
|
|
expect(pushSpy).toHaveBeenCalledExactlyOnceWith([
|
|
'--delete',
|
|
'origin',
|
|
'refs/renovate/branches/aaa',
|
|
'refs/renovate/branches/bbb',
|
|
'refs/renovate/branches/ccc',
|
|
]);
|
|
});
|
|
|
|
it('preserves unknown sections by default', async () => {
|
|
const commit = git.getBranchCommit('develop')!;
|
|
const tmpGit = simpleGit(tmpDir.path);
|
|
await tmpGit.raw(['update-ref', 'refs/renovate/foo/bar', commit]);
|
|
await tmpGit.raw([
|
|
'push',
|
|
'--force',
|
|
'origin',
|
|
'refs/renovate/foo/bar',
|
|
]);
|
|
await git.clearRenovateRefs();
|
|
await expect(lsRenovateRefs()).resolves.toEqual([
|
|
'refs/renovate/foo/bar',
|
|
]);
|
|
});
|
|
|
|
it('falls back to sequential ref deletion if bulk changes are disallowed', async () => {
|
|
const commit = git.getBranchCommit('develop')!;
|
|
await git.pushCommitToRenovateRef(commit, 'foo');
|
|
await git.pushCommitToRenovateRef(commit, 'bar');
|
|
await git.pushCommitToRenovateRef(commit, 'baz');
|
|
|
|
const pushSpy = vi.spyOn(SimpleGit.prototype, 'push');
|
|
pushSpy.mockImplementationOnce(() => {
|
|
throw new Error(
|
|
'remote: Repository policies do not allow pushes that update more than 2 branches or tags.',
|
|
);
|
|
});
|
|
|
|
await expect(lsRenovateRefs()).resolves.not.toBeEmpty();
|
|
await git.clearRenovateRefs();
|
|
await expect(lsRenovateRefs()).resolves.toBeEmpty();
|
|
expect(pushSpy).toHaveBeenCalledTimes(4);
|
|
});
|
|
});
|
|
|
|
describe('syncGit()', () => {
|
|
it('should clone a specified base branch', async () => {
|
|
tmpDir = await tmp.dir({ unsafeCleanup: true });
|
|
GlobalConfig.set({
|
|
baseBranchPatterns: ['develop'],
|
|
localDir: tmpDir.path,
|
|
});
|
|
await git.initRepo({
|
|
url: origin.path,
|
|
defaultBranch: 'develop',
|
|
});
|
|
await git.syncGit();
|
|
const tmpGit = simpleGit(tmpDir.path);
|
|
const branch = (
|
|
await tmpGit.raw(['rev-parse', '--abbrev-ref', 'HEAD'])
|
|
).trim();
|
|
expect(branch).toBe('develop');
|
|
});
|
|
|
|
it('should set core.hooksPath when RENOVATE_X_CLEAR_HOOKS is set', async () => {
|
|
// set up our repo again, so we can initialise it with `RENOVATE_X_CLEAR_HOOKS`
|
|
tmpDir = await tmp.dir({ unsafeCleanup: true });
|
|
GlobalConfig.set({ localDir: tmpDir.path });
|
|
vi.stubEnv('RENOVATE_X_CLEAR_HOOKS', 'true');
|
|
await git.initRepo({
|
|
url: origin.path,
|
|
});
|
|
|
|
// initialise the repo
|
|
await git.syncGit();
|
|
// then hit the RENOVATE_X_CLEAR_HOOKS code path
|
|
await git.syncGit();
|
|
|
|
const tmpGit = simpleGit(tmpDir.path);
|
|
const hooksPath = (
|
|
await tmpGit.raw(['config', 'core.hooksPath'])
|
|
).trim();
|
|
expect(hooksPath).toBe('/dev/null');
|
|
vi.stubEnv('RENOVATE_X_CLEAR_HOOKS', undefined);
|
|
});
|
|
|
|
it('should not inherit unsafe git environment variables from process.env', async () => {
|
|
vi.stubEnv('GIT_CONFIG_COUNT', '1');
|
|
vi.stubEnv('GIT_CONFIG_KEY_0', 'core.hooksPath');
|
|
vi.stubEnv('GIT_CONFIG_VALUE_0', '/tmp/hooks');
|
|
vi.stubEnv('GIT_CONFIG_GLOBAL', '/tmp/global-gitconfig');
|
|
vi.stubEnv('GIT_CONFIG_SYSTEM', '/tmp/system-gitconfig');
|
|
vi.stubEnv('PAGER', 'less');
|
|
vi.stubEnv('GIT_ASKPASS', '/tmp/.git-askpass');
|
|
// process.env deliberately overrides the default GIT_SSH_COMMAND, so an
|
|
// ambient value would be forwarded instead of the one asserted below.
|
|
vi.stubEnv('GIT_SSH_COMMAND', undefined);
|
|
|
|
const envSpy = vi.spyOn(SimpleGit.prototype, 'env');
|
|
await git.initRepo({ url: origin.path });
|
|
await expect(git.syncGit()).resolves.toBeUndefined();
|
|
expect(envSpy).toHaveBeenCalledTimes(1);
|
|
const [gitEnv] = envSpy.mock.calls[0];
|
|
expect(gitEnv).toEqual(
|
|
expect.objectContaining({
|
|
LANG: 'C.UTF-8',
|
|
LC_ALL: 'C.UTF-8',
|
|
GIT_SSH_COMMAND: 'ssh -o BatchMode=yes',
|
|
}),
|
|
);
|
|
expect(gitEnv).not.toHaveProperty('GIT_CONFIG_COUNT');
|
|
expect(gitEnv).not.toHaveProperty('GIT_CONFIG_KEY_0');
|
|
expect(gitEnv).not.toHaveProperty('GIT_CONFIG_VALUE_0');
|
|
expect(gitEnv).not.toHaveProperty('GIT_CONFIG_GLOBAL');
|
|
expect(gitEnv).not.toHaveProperty('GIT_CONFIG_SYSTEM');
|
|
expect(gitEnv).not.toHaveProperty('PAGER');
|
|
expect(gitEnv).not.toHaveProperty('GIT_ASKPASS');
|
|
});
|
|
|
|
it('should work when GIT_CONFIG_COUNT authentication environment variables are configured', async () => {
|
|
// Self-hosted users can opt into passing GIT_CONFIG_COUNT + GIT_CONFIG_KEY_n
|
|
// + GIT_CONFIG_VALUE_n via customEnvVariables.
|
|
// simple-git >=3.36.0 blocks git operations when these vars are present unless
|
|
// allowUnsafeConfigEnvCount is enabled in the simple-git config.
|
|
vi.stubEnv('GIT_SSH_COMMAND', undefined);
|
|
setCustomEnv({
|
|
GIT_CONFIG_COUNT: '3',
|
|
GIT_CONFIG_KEY_0: 'url.https://ssh:token@example.com/.insteadOf',
|
|
GIT_CONFIG_VALUE_0: 'ssh://git@example.com/',
|
|
GIT_CONFIG_KEY_1: 'url.https://git:token@example.com/.insteadOf',
|
|
GIT_CONFIG_VALUE_1: 'git@example.com:',
|
|
GIT_CONFIG_KEY_2: 'url.https://token@example.com/.insteadOf',
|
|
GIT_CONFIG_VALUE_2: 'https://example.com/',
|
|
});
|
|
|
|
const envSpy = vi.spyOn(SimpleGit.prototype, 'env');
|
|
await git.initRepo({ url: origin.path });
|
|
await expect(git.syncGit()).resolves.toBeUndefined();
|
|
expect(envSpy).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
GIT_CONFIG_COUNT: '3',
|
|
GIT_CONFIG_KEY_0: 'url.https://ssh:token@example.com/.insteadOf',
|
|
GIT_CONFIG_VALUE_0: 'ssh://git@example.com/',
|
|
GIT_CONFIG_KEY_1: 'url.https://git:token@example.com/.insteadOf',
|
|
GIT_CONFIG_VALUE_1: 'git@example.com:',
|
|
GIT_CONFIG_KEY_2: 'url.https://token@example.com/.insteadOf',
|
|
GIT_CONFIG_VALUE_2: 'https://example.com/',
|
|
LANG: 'C.UTF-8',
|
|
LC_ALL: 'C.UTF-8',
|
|
GIT_SSH_COMMAND: 'ssh -o BatchMode=yes',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should allow customEnvVariables to override GIT_SSH_COMMAND', async () => {
|
|
// Self-hosted users may inject a custom GIT_SSH_COMMAND via
|
|
// customEnvVariables to configure SSH authentication (e.g. a
|
|
// specific identity file). The default 'ssh -o BatchMode=yes'
|
|
// should be used as a fallback, not as a forced override.
|
|
const customSshCommand =
|
|
'ssh -i /path/to/deploy-key -o StrictHostKeyChecking=no';
|
|
setCustomEnv({ GIT_SSH_COMMAND: customSshCommand });
|
|
|
|
const envSpy = vi.spyOn(SimpleGit.prototype, 'env');
|
|
await git.initRepo({ url: origin.path });
|
|
await expect(git.syncGit()).resolves.toBeUndefined();
|
|
expect(envSpy).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
LANG: 'C.UTF-8',
|
|
LC_ALL: 'C.UTF-8',
|
|
GIT_SSH_COMMAND: customSshCommand,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should allow customEnvVariables to override GIT_SSH_COMMAND alongside other custom vars', async () => {
|
|
const customSshCommand =
|
|
'ssh -i /path/to/deploy-key -o StrictHostKeyChecking=no';
|
|
setCustomEnv({
|
|
GIT_SSH_COMMAND: customSshCommand,
|
|
CUSTOM_TOKEN: 'abc123',
|
|
});
|
|
|
|
const envSpy = vi.spyOn(SimpleGit.prototype, 'env');
|
|
await git.initRepo({ url: origin.path });
|
|
await expect(git.syncGit()).resolves.toBeUndefined();
|
|
expect(envSpy).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
LANG: 'C.UTF-8',
|
|
LC_ALL: 'C.UTF-8',
|
|
GIT_SSH_COMMAND: customSshCommand,
|
|
CUSTOM_TOKEN: 'abc123',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should allow process.env GIT_SSH_COMMAND to override the default', async () => {
|
|
// GIT_SSH_COMMAND is declared in extraEnv, so the key is inherited
|
|
// from process.env via parentEnv (higher priority than extraEnv).
|
|
vi.stubEnv('GIT_SSH_COMMAND', 'ssh -o SomeHostOption=yes');
|
|
|
|
const envSpy = vi.spyOn(SimpleGit.prototype, 'env');
|
|
await git.initRepo({ url: origin.path });
|
|
await expect(git.syncGit()).resolves.toBeUndefined();
|
|
expect(envSpy).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
GIT_SSH_COMMAND: 'ssh -o SomeHostOption=yes',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should allow customEnvVariables to override GIT_ASKPASS', async () => {
|
|
// Self-hosted users may inject a custom GIT_ASKPASS via
|
|
// customEnvVariables to configure git authentication.
|
|
const customAskPassCommand = '/tmp/.git-askpass';
|
|
setCustomEnv({ GIT_ASKPASS: customAskPassCommand });
|
|
|
|
const envSpy = vi.spyOn(SimpleGit.prototype, 'env');
|
|
await git.initRepo({ url: origin.path });
|
|
await expect(git.syncGit()).resolves.toBeUndefined();
|
|
expect(envSpy).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
LANG: 'C.UTF-8',
|
|
LC_ALL: 'C.UTF-8',
|
|
GIT_ASKPASS: customAskPassCommand,
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('pushCommit', () => {
|
|
it('should pass pushOptions to git.push', async () => {
|
|
const pushSpy = vi
|
|
.spyOn(SimpleGit.prototype, 'push')
|
|
.mockResolvedValue(partial<PushResult>());
|
|
await expect(
|
|
git.pushCommit({
|
|
sourceRef: defaultBranch,
|
|
targetRef: defaultBranch,
|
|
files: [],
|
|
pushOptions: ['ci.skip', 'foo=bar'],
|
|
}),
|
|
).resolves.toBeTrue();
|
|
expect(pushSpy).toHaveBeenCalledExactlyOnceWith(
|
|
'origin',
|
|
`${defaultBranch}:${defaultBranch}`,
|
|
expect.objectContaining({
|
|
'--push-option': ['ci.skip', 'foo=bar'],
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('forkMode - normal working', () => {
|
|
let upstreamBase: tmp.DirectoryResult;
|
|
let upstreamOrigin: tmp.DirectoryResult;
|
|
let tmpDir2: tmp.DirectoryResult;
|
|
|
|
beforeAll(async () => {
|
|
// create an upstream branch and one extra branch in it
|
|
upstreamBase = await tmp.dir({ unsafeCleanup: true });
|
|
const upstream = simpleGit(upstreamBase.path);
|
|
await upstream.init();
|
|
await disableGitAutoMaintenance(upstream);
|
|
const defaultUpsBranch = (
|
|
await upstream.raw('branch', '--show-current')
|
|
).trim();
|
|
await upstream.addConfig('user.email', 'other@example.com');
|
|
await upstream.addConfig('user.name', 'Other');
|
|
await fs.writeFile(`${upstreamBase.path}/past_file`, 'past');
|
|
await upstream.addConfig('commit.gpgsign', 'false');
|
|
await upstream.add(['past_file']);
|
|
await upstream.commit('past message');
|
|
await upstream.raw(['checkout', '-B', defaultUpsBranch]);
|
|
await upstream.checkout(['-b', 'develop', defaultUpsBranch]);
|
|
|
|
// clone of upstream on local path
|
|
upstreamOrigin = await tmp.dir({ unsafeCleanup: true });
|
|
const upstreamRepo = simpleGit(upstreamOrigin.path);
|
|
await upstreamRepo.clone(upstreamBase.path, '.', ['--bare']);
|
|
await disableGitAutoMaintenance(upstreamRepo);
|
|
await upstreamRepo.addConfig('commit.gpgsign', 'false');
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await upstreamBase?.cleanup();
|
|
await upstreamOrigin?.cleanup();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await tmpDir2?.cleanup();
|
|
});
|
|
|
|
describe('syncForkWithUpstream()', () => {
|
|
it('throws unknown error', async () => {
|
|
tmpDir2 = await tmp.dir({ unsafeCleanup: true });
|
|
GlobalConfig.set({ localDir: tmpDir2.path });
|
|
|
|
await git.initRepo({
|
|
url: origin.path,
|
|
defaultBranch,
|
|
upstreamUrl: upstreamOrigin.path,
|
|
});
|
|
|
|
await git.syncGit();
|
|
await expect(
|
|
git.syncForkWithUpstream('non-existing-branch'),
|
|
).rejects.toThrow(UNKNOWN_ERROR);
|
|
});
|
|
|
|
it('syncs fork when local for branch absent', async () => {
|
|
tmpDir2 = await tmp.dir({ unsafeCleanup: true });
|
|
GlobalConfig.set({ localDir: tmpDir2.path });
|
|
|
|
// init fork repo
|
|
await git.initRepo({
|
|
url: origin.path,
|
|
defaultBranch,
|
|
upstreamUrl: upstreamOrigin.path,
|
|
});
|
|
|
|
await git.syncGit();
|
|
await expect(git.syncForkWithUpstream('develop')).toResolve();
|
|
|
|
expect(logger.logger.debug).toHaveBeenCalledWith(
|
|
'Checking out branch develop from remote renovate-fork-upstream',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('syncGit()', () => {
|
|
it('should fetch from upstream and update local branch', async () => {
|
|
tmpDir2 = await tmp.dir({ unsafeCleanup: true });
|
|
GlobalConfig.set({ localDir: tmpDir2.path });
|
|
|
|
await git.initRepo({
|
|
url: origin.path,
|
|
defaultBranch,
|
|
upstreamUrl: upstreamOrigin.path,
|
|
});
|
|
|
|
await git.syncGit();
|
|
const tmpGit = simpleGit(tmpDir2.path);
|
|
|
|
// make sure origin exists ie. fork repo is cloned
|
|
const originRemote = (
|
|
await tmpGit.raw(['remote', 'get-url', 'origin'])
|
|
).trim();
|
|
expect(originRemote.trim()).toBe(origin.path);
|
|
|
|
// make sure upstream exists
|
|
const upstreamRemote = (
|
|
await tmpGit.raw(['remote', 'get-url', git.RENOVATE_FORK_UPSTREAM])
|
|
).trim();
|
|
expect(upstreamRemote).toBe(upstreamOrigin.path);
|
|
|
|
// verify fetch from upstream happened
|
|
// by checking the `${RENOVATE_FORK_UPSTREAM}/main` branch in the forked repo's remote branches
|
|
const branches = await tmpGit.branch(['-r']);
|
|
expect(branches.all).toContain(
|
|
`${git.RENOVATE_FORK_UPSTREAM}/${defaultBranch}`,
|
|
);
|
|
|
|
// verify that the HEAD's match
|
|
const headSha = (await tmpGit.revparse(['HEAD'])).trim();
|
|
const upstreamSha = (
|
|
await tmpGit.revparse([
|
|
`${git.RENOVATE_FORK_UPSTREAM}/${defaultBranch}`,
|
|
])
|
|
).trim();
|
|
expect(headSha).toBe(upstreamSha);
|
|
});
|
|
});
|
|
});
|
|
|
|
// for coverage mostly
|
|
describe('forkMode - errors', () => {
|
|
it('resetHardFromRemote()', async () => {
|
|
const resetSpy = vi.spyOn(SimpleGit.prototype, 'reset');
|
|
resetSpy.mockRejectedValueOnce(new Error('reset error'));
|
|
await expect(git.resetHardFromRemote('branchName')).rejects.toThrow(
|
|
'reset error',
|
|
);
|
|
});
|
|
|
|
it('forcePushToRemote()', async () => {
|
|
const pushSpy = vi.spyOn(SimpleGit.prototype, 'push');
|
|
pushSpy.mockRejectedValueOnce(new Error('push error'));
|
|
await expect(git.forcePushToRemote('branch', 'origin')).rejects.toThrow(
|
|
'push error',
|
|
);
|
|
});
|
|
|
|
it('checkoutBranchFromRemote()', async () => {
|
|
const checkoutSpy = vi.spyOn(SimpleGit.prototype, 'checkoutBranch');
|
|
checkoutSpy.mockRejectedValueOnce(new Error('checkout error'));
|
|
await expect(
|
|
git.checkoutBranchFromRemote('branch', git.RENOVATE_FORK_UPSTREAM),
|
|
).rejects.toThrow('checkout error');
|
|
});
|
|
|
|
it('checkoutBranchFromRemote() - temporary error', async () => {
|
|
const checkoutSpy = vi.spyOn(SimpleGit.prototype, 'checkoutBranch');
|
|
checkoutSpy.mockRejectedValueOnce(
|
|
new Error('fatal: ambiguous argument'),
|
|
);
|
|
await expect(
|
|
git.checkoutBranchFromRemote('branch', git.RENOVATE_FORK_UPSTREAM),
|
|
).rejects.toThrow(TEMPORARY_ERROR);
|
|
});
|
|
|
|
it('syncForkWithRemote() - returns if no upstream exists', async () => {
|
|
await git.initRepo({
|
|
url: origin.path,
|
|
defaultBranch,
|
|
});
|
|
|
|
await expect(git.syncForkWithUpstream(defaultBranch)).toResolve();
|
|
});
|
|
});
|
|
|
|
describe('virtualBranches', () => {
|
|
it('fetches refspecs and populates branchCommits', async () => {
|
|
const originRepo = simpleGit(origin.path);
|
|
const commit = toLongCommitSha(
|
|
(await originRepo.revparse(['HEAD'])).trim(),
|
|
);
|
|
await originRepo.raw(['update-ref', 'refs/changes/45/12345/1', commit]);
|
|
|
|
await git.initRepo({
|
|
url: origin.path,
|
|
virtualBranches: {
|
|
'renovate/typescript-5.x': {
|
|
ref: 'refs/changes/45/12345/1',
|
|
sha: commit,
|
|
},
|
|
},
|
|
});
|
|
await git.syncGit();
|
|
|
|
expect(git.branchExists('renovate/typescript-5.x')).toBeTrue();
|
|
expect(git.getBranchCommit('renovate/typescript-5.x')).toBe(commit);
|
|
});
|
|
|
|
it('throws on fetch error', async () => {
|
|
const originRepo = simpleGit(origin.path);
|
|
const commit = toLongCommitSha(
|
|
(await originRepo.revparse(['HEAD'])).trim(),
|
|
);
|
|
|
|
await git.initRepo({
|
|
url: origin.path,
|
|
virtualBranches: {
|
|
'renovate/node-22.x': {
|
|
ref: 'refs/changes/99/99999/1',
|
|
sha: commit,
|
|
},
|
|
},
|
|
});
|
|
|
|
await expect(git.syncGit()).rejects.toThrow(
|
|
"fatal: couldn't find remote ref refs/changes/99/99999/1",
|
|
);
|
|
});
|
|
|
|
it('handles multiple refspecs', async () => {
|
|
const baseRepo = simpleGit(base.path);
|
|
const commits: LongCommitSha[] = [];
|
|
|
|
await fs.writeFile(`${base.path}/temp_1.txt`, 'content-1');
|
|
await baseRepo.add(['temp_1.txt']);
|
|
await baseRepo.commit('commit for ref 1');
|
|
const commit1 = toLongCommitSha(
|
|
(await baseRepo.revparse(['HEAD'])).trim(),
|
|
);
|
|
commits.push(commit1);
|
|
await baseRepo.raw(['update-ref', 'refs/changes/01/1001/1', commit1]);
|
|
|
|
await fs.writeFile(`${base.path}/temp_2.txt`, 'content-2');
|
|
await baseRepo.add(['temp_2.txt']);
|
|
await baseRepo.commit('commit for ref 2');
|
|
const commit2 = toLongCommitSha(
|
|
(await baseRepo.revparse(['HEAD'])).trim(),
|
|
);
|
|
commits.push(commit2);
|
|
await baseRepo.raw(['update-ref', 'refs/changes/02/1002/1', commit2]);
|
|
|
|
const originRepo = simpleGit(origin.path);
|
|
await originRepo.fetch([`file://${base.path}`, '+refs/*:refs/*']);
|
|
|
|
await git.initRepo({
|
|
url: origin.path,
|
|
virtualBranches: {
|
|
'renovate/dep1': {
|
|
ref: 'refs/changes/01/1001/1',
|
|
sha: commit1,
|
|
},
|
|
'renovate/dep2': {
|
|
ref: 'refs/changes/02/1002/1',
|
|
sha: commit2,
|
|
},
|
|
},
|
|
});
|
|
await git.syncGit();
|
|
|
|
expect(git.branchExists('renovate/dep1')).toBeTrue();
|
|
expect(git.branchExists('renovate/dep2')).toBeTrue();
|
|
expect(git.getBranchCommit('renovate/dep1')).toBe(commits[0]);
|
|
expect(git.getBranchCommit('renovate/dep2')).toBe(commits[1]);
|
|
});
|
|
});
|
|
|
|
describe('deleteBranch() for virtual branches', () => {
|
|
it('deletes local branch and remote tracking ref', async () => {
|
|
const originRepo = simpleGit(origin.path);
|
|
const commit = toLongCommitSha(
|
|
(await originRepo.revparse(['HEAD'])).trim(),
|
|
);
|
|
await originRepo.raw(['update-ref', 'refs/changes/50/12350/1', commit]);
|
|
|
|
await git.initRepo({
|
|
url: origin.path,
|
|
virtualBranches: {
|
|
'renovate/npm-lodash-4.x': {
|
|
ref: 'refs/changes/50/12350/1',
|
|
sha: commit,
|
|
},
|
|
},
|
|
});
|
|
await git.syncGit();
|
|
|
|
const pushSpy = vi.spyOn(SimpleGit.prototype, 'push');
|
|
await git.deleteBranch('renovate/npm-lodash-4.x');
|
|
|
|
expect(git.branchExists('renovate/npm-lodash-4.x')).toBeFalse();
|
|
expect(pushSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('handles a missing remote-tracking ref', async () => {
|
|
const originRepo = simpleGit(origin.path);
|
|
const commit = toLongCommitSha(
|
|
(await originRepo.revparse(['HEAD'])).trim(),
|
|
);
|
|
await git.setVirtualBranch(
|
|
'nonexistent',
|
|
'refs/changes/99/99999/1',
|
|
commit,
|
|
);
|
|
// Simulate the remote-tracking ref already being gone
|
|
await simpleGit(tmpDir.path).raw([
|
|
'update-ref',
|
|
'-d',
|
|
'refs/remotes/origin/nonexistent',
|
|
]);
|
|
await expect(git.deleteBranch('nonexistent')).resolves.not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('mergeToLocal() for virtual branches', () => {
|
|
it('merges the remote-tracking ref of an init virtual branch', async () => {
|
|
const originRepo = simpleGit(origin.path);
|
|
const commit = toLongCommitSha(
|
|
(await originRepo.revparse(['HEAD'])).trim(),
|
|
);
|
|
await originRepo.raw(['update-ref', 'refs/changes/70/12370/1', commit]);
|
|
|
|
await git.initRepo({
|
|
url: origin.path,
|
|
virtualBranches: {
|
|
'renovate/virtual-merge': {
|
|
ref: 'refs/changes/70/12370/1',
|
|
sha: commit,
|
|
},
|
|
},
|
|
});
|
|
await git.syncGit();
|
|
|
|
const fetchSpy = vi.spyOn(SimpleGit.prototype, 'fetch');
|
|
const mergeSpy = vi.spyOn(SimpleGit.prototype, 'merge');
|
|
await git.mergeToLocal('renovate/virtual-merge');
|
|
|
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
expect(mergeSpy).toHaveBeenCalledWith([
|
|
'refs/remotes/origin/renovate/virtual-merge',
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('setVirtualBranch()', () => {
|
|
it('marks branch as not modified after registration', async () => {
|
|
modifiedCache.getCachedModifiedResult.mockReturnValue(null);
|
|
const originRepo = simpleGit(origin.path);
|
|
const commit = toLongCommitSha(
|
|
(await originRepo.revparse(['HEAD'])).trim(),
|
|
);
|
|
await originRepo.raw(['update-ref', 'refs/changes/60/12360/1', commit]);
|
|
|
|
await git.initRepo({
|
|
url: origin.path,
|
|
virtualBranches: {
|
|
'renovate/virtual-test': {
|
|
ref: 'refs/changes/60/12360/1',
|
|
sha: commit,
|
|
},
|
|
},
|
|
});
|
|
await git.syncGit();
|
|
await git.checkoutBranch('renovate/virtual-test');
|
|
|
|
await git.setVirtualBranch(
|
|
'renovate/virtual-test',
|
|
'refs/changes/60/12360/2',
|
|
commit,
|
|
);
|
|
|
|
await expect(
|
|
git.isBranchModified('renovate/virtual-test', defaultBranch),
|
|
).resolves.toBeFalse();
|
|
});
|
|
});
|
|
});
|
|
});
|