docusaurus/packages/docusaurus-utils/src/vcs/__tests__/gitUtils.test.ts
2025-11-07 15:29:28 +01:00

492 lines
14 KiB
TypeScript

/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import fs from 'fs-extra';
import path from 'path';
import os from 'os';
import execa from 'execa';
import {
FileNotTrackedError,
getFileCommitDate,
getGitLastUpdate,
getGitCreation,
getGitRepoRoot,
getGitSuperProjectRoot,
} from '../gitUtils';
class Git {
private constructor(private dir: string) {
this.dir = dir;
}
private static async runOptimisticGitCommand({
cwd,
cmd,
args,
options,
}: {
cwd: string;
args: string[];
cmd: string;
options?: execa.Options;
}): Promise<execa.ExecaReturnValue> {
const res = await execa(cmd, args, {
cwd,
silent: true,
shell: true,
...options,
});
if (res.exitCode !== 0) {
throw new Error(
`Git command failed with code ${res.exitCode}: ${cmd} ${args.join(
' ',
)}`,
);
}
return res;
}
static async initializeRepo(dir: string): Promise<Git> {
await Git.runOptimisticGitCommand({
cmd: 'git',
args: ['init'],
cwd: dir,
});
await Git.runOptimisticGitCommand({
cmd: 'git',
args: ['config', 'user.email', '"test@example.com"'],
cwd: dir,
});
await Git.runOptimisticGitCommand({
cmd: 'git',
args: ['config', 'user.name', '"Test"'],
cwd: dir,
});
await Git.runOptimisticGitCommand({
cmd: 'git',
args: ['commit', '--allow-empty', '-m "First commit"'],
cwd: dir,
});
return new Git(dir);
}
async runOptimisticGitCommand(
cmd: string,
args?: string[],
options?: execa.Options,
): Promise<execa.ExecaReturnValue> {
return Git.runOptimisticGitCommand({cwd: this.dir, cmd, args, options});
}
async add(filePath: string): Promise<void> {
await this.runOptimisticGitCommand('git', ['add', filePath]);
}
async addAll(): Promise<void> {
await this.runOptimisticGitCommand('git', ['add', '.']);
}
async commit(msg: string, date: string, author: string): Promise<void> {
await this.runOptimisticGitCommand(
`git`,
[
'commit',
`-m "${msg}"`,
`--date "${date}T00:00:00Z"`,
`--author "${author}"`,
],
{env: {GIT_COMMITTER_DATE: `${date}T00:00:00Z`}},
);
}
async commitFile(
filePath: string,
{
fileContent,
commitMessage,
commitDate,
commitAuthor,
}: {
fileContent?: string;
commitMessage?: string;
commitDate?: string;
commitAuthor?: string;
} = {},
): Promise<void> {
await fs.ensureDir(path.join(this.dir, path.dirname(filePath)));
await fs.writeFile(
path.join(this.dir, filePath),
fileContent ?? `Content of ${filePath}`,
);
await this.add(filePath);
await this.commit(
commitMessage ?? `Create ${filePath}`,
commitDate ?? '2020-06-19',
commitAuthor ?? 'Seb <seb@example.com>',
);
}
async addSubmodule(name: string, repoPath: string): Promise<void> {
return this.runOptimisticGitCommand('git', [
'-c protocol.file.allow=always',
'submodule',
'add',
repoPath,
name,
]);
}
async defineSubmodules(submodules: {[name: string]: string}): Promise<void> {
for (const entry of Object.entries(submodules)) {
await this.addSubmodule(entry[0], entry[1]);
}
await this.runOptimisticGitCommand('git', [
'submodule',
'update',
'--init',
'--recursive',
]);
}
}
async function createGitRepoEmpty(): Promise<{repoDir: string; git: Git}> {
let repoDir = await fs.mkdtemp(path.join(os.tmpdir(), 'git-test-repo'));
repoDir = await fs.realpath.native(repoDir);
const git = await Git.initializeRepo(repoDir);
return {repoDir, git};
}
describe('commit info APIs', () => {
async function createGitRepoTestFixture() {
const {repoDir, git} = await createGitRepoEmpty();
await git.commitFile('test.txt', {
fileContent: 'Some content',
commitMessage: 'Create test.txt',
commitDate: '2020-06-19',
commitAuthor: 'Caroline <caroline@example.com>',
});
await git.commitFile('test.txt', {
fileContent: 'Updated content',
commitMessage: 'Update test.txt',
commitDate: '2020-06-20',
commitAuthor: 'Josh-Cena <josh-cena@example.com>',
});
await fs.writeFile(path.join(repoDir, 'test.txt'), 'Updated content (2)');
await fs.writeFile(path.join(repoDir, 'moved.txt'), 'This file is moved');
await git.addAll();
await git.commit(
'Update test.txt again, create moved.txt',
'2020-09-13',
'Caroline <caroline@example.com>',
);
await fs.move(
path.join(repoDir, 'moved.txt'),
path.join(repoDir, 'dest.txt'),
);
await git.addAll();
await git.commit(
'Rename moved.txt to dest.txt',
'2020-11-13',
'Josh-Cena <josh-cena@example.com>',
);
await fs.writeFile(path.join(repoDir, 'untracked.txt'), "I'm untracked");
return repoDir;
}
describe('getFileCommitDate', () => {
it('returns earliest commit date', async () => {
const repoDir = await createGitRepoTestFixture();
await expect(
getFileCommitDate(path.join(repoDir, 'test.txt'), {}),
).resolves.toEqual({
date: new Date('2020-06-19'),
timestamp: new Date('2020-06-19').getTime(),
});
await expect(
getFileCommitDate(path.join(repoDir, 'dest.txt'), {}),
).resolves.toEqual({
date: new Date('2020-09-13'),
timestamp: new Date('2020-09-13').getTime(),
});
});
it('returns latest commit date', async () => {
const repoDir = await createGitRepoTestFixture();
await expect(
getFileCommitDate(path.join(repoDir, 'test.txt'), {age: 'newest'}),
).resolves.toEqual({
date: new Date('2020-09-13'),
timestamp: new Date('2020-09-13').getTime(),
});
await expect(
getFileCommitDate(path.join(repoDir, 'dest.txt'), {age: 'newest'}),
).resolves.toEqual({
date: new Date('2020-11-13'),
timestamp: new Date('2020-11-13').getTime(),
});
});
it('returns latest commit date with author', async () => {
const repoDir = await createGitRepoTestFixture();
await expect(
getFileCommitDate(path.join(repoDir, 'test.txt'), {
age: 'oldest',
includeAuthor: true,
}),
).resolves.toEqual({
date: new Date('2020-06-19'),
timestamp: new Date('2020-06-19').getTime(),
author: 'Caroline',
});
await expect(
getFileCommitDate(path.join(repoDir, 'dest.txt'), {
age: 'oldest',
includeAuthor: true,
}),
).resolves.toEqual({
date: new Date('2020-09-13'),
timestamp: new Date('2020-09-13').getTime(),
author: 'Caroline',
});
});
it('returns earliest commit date with author', async () => {
const repoDir = await createGitRepoTestFixture();
await expect(
getFileCommitDate(path.join(repoDir, 'test.txt'), {
age: 'newest',
includeAuthor: true,
}),
).resolves.toEqual({
date: new Date('2020-09-13'),
timestamp: new Date('2020-09-13').getTime(),
author: 'Caroline',
});
await expect(
getFileCommitDate(path.join(repoDir, 'dest.txt'), {
age: 'newest',
includeAuthor: true,
}),
).resolves.toEqual({
date: new Date('2020-11-13'),
timestamp: new Date('2020-11-13').getTime(),
author: 'Josh-Cena',
});
});
it('throws custom error when file is not tracked', async () => {
const repoDir = await createGitRepoTestFixture();
await expect(() =>
getFileCommitDate(path.join(repoDir, 'untracked.txt'), {
age: 'newest',
includeAuthor: true,
}),
).rejects.toThrow(FileNotTrackedError);
});
it('throws when file not found', async () => {
const repoDir = await createGitRepoTestFixture();
await expect(() =>
getFileCommitDate(path.join(repoDir, 'nonexistent.txt'), {
age: 'newest',
includeAuthor: true,
}),
).rejects.toThrow(
/Failed to retrieve git history for ".*nonexistent.txt" because the file does not exist./,
);
});
});
describe('commit info APIs', () => {
it('returns creation info for test.txt', async () => {
const repoDir = await createGitRepoTestFixture();
const filePath = path.join(repoDir, 'test.txt');
await expect(getGitCreation(filePath)).resolves.toEqual({
author: 'Caroline',
timestamp: new Date('2020-06-19').getTime(),
});
await expect(getGitLastUpdate(filePath)).resolves.toEqual({
author: 'Caroline',
timestamp: new Date('2020-09-13').getTime(),
});
});
it('returns creation info for dest.txt', async () => {
const repoDir = await createGitRepoTestFixture();
const filePath = path.join(repoDir, 'dest.txt');
await expect(getGitCreation(filePath)).resolves.toEqual({
author: 'Caroline',
timestamp: new Date('2020-09-13').getTime(),
});
await expect(getGitLastUpdate(filePath)).resolves.toEqual({
author: 'Josh-Cena',
timestamp: new Date('2020-11-13').getTime(),
});
});
it('returns creation info for untracked.txt', async () => {
const repoDir = await createGitRepoTestFixture();
const filePath = path.join(repoDir, 'untracked.txt');
await expect(getGitCreation(filePath)).resolves.toEqual(null);
await expect(getGitLastUpdate(filePath)).resolves.toEqual(null);
});
it('returns creation info for non-existing.txt', async () => {
const repoDir = await createGitRepoTestFixture();
const filePath = path.join(repoDir, 'non-existing.txt');
await expect(
getGitCreation(filePath),
).rejects.toThrowErrorMatchingInlineSnapshot(
`"An error occurred when trying to get the last update date"`,
);
await expect(
getGitLastUpdate(filePath),
).rejects.toThrowErrorMatchingInlineSnapshot(
`"An error occurred when trying to get the last update date"`,
);
});
});
});
describe('getGitRepoRoot', () => {
async function initTestRepo() {
const {repoDir, git} = await createGitRepoEmpty();
await git.commitFile('subDir/test.txt');
return repoDir;
}
it('returns repoDir for cwd=repoDir', async () => {
const repoDir = await initTestRepo();
const cwd = repoDir;
await expect(getGitRepoRoot(cwd)).resolves.toEqual(repoDir);
});
it('returns repoDir for cwd=repoDir/subDir', async () => {
const repoDir = await initTestRepo();
const cwd = path.join(repoDir, 'subDir');
await expect(getGitRepoRoot(cwd)).resolves.toEqual(repoDir);
});
it('returns Docusaurus repo for cwd=__dirname', async () => {
const cwd = __dirname;
await expect(getGitRepoRoot(cwd)).resolves.toMatch(/docusaurus$/);
});
it('rejects for cwd=repoDir/doesNotExist', async () => {
const repoDir = await initTestRepo();
const cwd = path.join(repoDir, 'doesNotExist');
await expect(getGitRepoRoot(cwd)).rejects.toThrow(
/Couldn't find the git repository root directory/,
);
});
});
describe('submodules APIs', () => {
async function initTestRepo() {
const superproject = await createGitRepoEmpty();
await superproject.git.commitFile('README.md');
await superproject.git.commitFile('website/docs/myDoc.md');
const submodule1 = await createGitRepoEmpty();
await submodule1.git.commitFile('file1.txt');
const submodule2 = await createGitRepoEmpty();
await submodule2.git.commitFile('subDir/file2.txt');
await superproject.git.defineSubmodules({
'submodules/submodule1': submodule1.repoDir,
'submodules/submodule2': submodule2.repoDir,
});
return {superproject, submodule1, submodule2};
}
describe('getGitSuperProjectRoot', () => {
it('returns superproject dir for cwd=superproject', async () => {
const repo = await initTestRepo();
const cwd = path.join(repo.superproject.repoDir);
await expect(getGitSuperProjectRoot(cwd)).resolves.toEqual(
repo.superproject.repoDir,
);
});
it('returns superproject dir for cwd=superproject/submodules', async () => {
const repo = await initTestRepo();
const cwd = path.join(repo.superproject.repoDir, 'submodules');
await expect(getGitSuperProjectRoot(cwd)).resolves.toEqual(
repo.superproject.repoDir,
);
});
it('returns superproject dir for cwd=superproject/website/docs', async () => {
const repo = await initTestRepo();
const cwd = path.join(repo.superproject.repoDir, 'website/docs');
await expect(getGitSuperProjectRoot(cwd)).resolves.toEqual(
repo.superproject.repoDir,
);
});
it('returns superproject dir for cwd=submodule1', async () => {
const repo = await initTestRepo();
const cwd = path.join(repo.superproject.repoDir, 'submodules/submodule1');
await expect(getGitSuperProjectRoot(cwd)).resolves.toEqual(
repo.superproject.repoDir,
);
});
it('returns superproject dir for cwd=submodule2', async () => {
const repo = await initTestRepo();
const cwd = path.join(repo.superproject.repoDir, 'submodules/submodule2');
await expect(getGitSuperProjectRoot(cwd)).resolves.toEqual(
repo.superproject.repoDir,
);
});
it('returns superproject dir for cwd=submodule2/subDir', async () => {
const repo = await initTestRepo();
const cwd = path.join(
repo.superproject.repoDir,
'submodules/submodule2/subDir',
);
await expect(getGitSuperProjectRoot(cwd)).resolves.toEqual(
repo.superproject.repoDir,
);
});
it('rejects for cwd of untracked dir', async () => {
const cwd = await os.tmpdir();
// Do we really want this to throw?
// Not sure, and Git doesn't help us failsafe and return null...
await expect(getGitSuperProjectRoot(cwd)).rejects
.toThrowErrorMatchingInlineSnapshot(`
"Couldn't find the git superproject root directory
Failure while running \`git rev-parse --show-superproject-working-tree\` from cwd="<TEMP_DIR>"
The command executed throws an error: Command failed with exit code 128: git rev-parse --show-superproject-working-tree
fatal: not a git repository (or any of the parent directories): .git"
`);
});
});
});