mirror of
https://github.com/game-ci/unity-builder.git
synced 2025-07-04 12:25:19 -04:00
Implement versioning strategies in js 🧉
This commit is contained in:
parent
2e81e61af3
commit
d75d7890d0
@ -27,7 +27,7 @@ namespace UnityBuilderAction
|
||||
};
|
||||
|
||||
// Set version for this build
|
||||
VersionApplicator.SetVersion(options["versioning"], options["version"]);
|
||||
VersionApplicator.SetVersion(options["version"]);
|
||||
|
||||
// Perform build
|
||||
BuildReport buildReport = BuildPipeline.BuildPlayer(buildOptions);
|
||||
|
@ -10,7 +10,7 @@ namespace UnityBuilderAction.Versioning
|
||||
/// <summary>
|
||||
/// Generate a version based on the latest tag and the amount of commits.
|
||||
/// Format: 0.1.2 (where 2 is the amount of commits).
|
||||
///
|
||||
///
|
||||
/// If no tag is present in the repository then v0.0 is assumed.
|
||||
/// This would result in 0.0.# where # is the amount of commits.
|
||||
/// </summary>
|
||||
@ -32,7 +32,7 @@ namespace UnityBuilderAction.Versioning
|
||||
|
||||
/// <summary>
|
||||
/// Get the version of the current tag.
|
||||
///
|
||||
///
|
||||
/// The tag must point at HEAD for this method to work.
|
||||
///
|
||||
/// Output Format:
|
||||
@ -85,7 +85,7 @@ namespace UnityBuilderAction.Versioning
|
||||
|
||||
/// <summary>
|
||||
/// Get version string.
|
||||
///
|
||||
///
|
||||
/// Format: `v0.1-2-g12345678` (where 2 is the amount of commits since the last tag)
|
||||
///
|
||||
/// See: https://softwareengineering.stackexchange.com/questions/141973/how-do-you-achieve-a-numeric-versioning-scheme-with-git
|
||||
@ -95,7 +95,7 @@ namespace UnityBuilderAction.Versioning
|
||||
return Run(@"describe --tags --long --match ""v[0-9]*""");
|
||||
|
||||
// Todo - implement split function based on this more complete query
|
||||
return Run(@"git describe --long --tags --dirty --always");
|
||||
// return Run(@"describe --long --tags --dirty --always");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
@ -6,53 +6,12 @@ namespace UnityBuilderAction.Versioning
|
||||
{
|
||||
public class VersionApplicator
|
||||
{
|
||||
enum Strategy
|
||||
public static void SetVersion(string version)
|
||||
{
|
||||
None,
|
||||
Custom,
|
||||
Semantic,
|
||||
Tag,
|
||||
}
|
||||
|
||||
public static void SetVersion(string strategy, [CanBeNull] string version)
|
||||
{
|
||||
if (!Enum.TryParse<Strategy>(strategy, out Strategy validatedStrategy)) {
|
||||
throw new Exception($"Invalid versioning argument provided. {strategy} is not a valid strategy.");
|
||||
if (version == "none") {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (validatedStrategy) {
|
||||
case Strategy.None:
|
||||
return;
|
||||
case Strategy.Custom:
|
||||
ApplyCustomVersion(version);
|
||||
return;
|
||||
case Strategy.Semantic:
|
||||
ApplySemanticCommitVersion();
|
||||
return;
|
||||
case Strategy.Tag:
|
||||
ApplyVersionFromCurrentTag();
|
||||
return;
|
||||
default:
|
||||
throw new NotImplementedException("Version strategy has not been implemented.");
|
||||
}
|
||||
}
|
||||
|
||||
static void ApplyCustomVersion(string version)
|
||||
{
|
||||
Apply(version);
|
||||
}
|
||||
|
||||
static void ApplySemanticCommitVersion()
|
||||
{
|
||||
string version = Git.GenerateSemanticCommitVersion();
|
||||
|
||||
Apply(version);
|
||||
}
|
||||
|
||||
static void ApplyVersionFromCurrentTag()
|
||||
{
|
||||
string version = Git.GetTagVersion();
|
||||
|
||||
|
||||
Apply(version);
|
||||
}
|
||||
|
||||
|
File diff suppressed because one or more lines are too long
@ -5,4 +5,5 @@ module.exports = {
|
||||
moduleFileExtensions: ['js', 'jsx', 'json', 'vue'],
|
||||
transform: { '^.+\\.(js|jsx)?$': 'babel-jest' },
|
||||
transformIgnorePatterns: [`/node_modules/(?!${esModules})`],
|
||||
setupFilesAfterEnv: ['./src/jest.setup.js'],
|
||||
};
|
||||
|
@ -7,8 +7,8 @@ async function action() {
|
||||
Cache.verify();
|
||||
|
||||
const { dockerfile, workspace, actionFolder } = Action;
|
||||
const buildParameters = BuildParameters.create(Input.getFromUser());
|
||||
const baseImage = new ImageTag({ ...buildParameters, version: buildParameters.unityVersion });
|
||||
const buildParameters = BuildParameters.create(await Input.getFromUser());
|
||||
const baseImage = new ImageTag(buildParameters);
|
||||
|
||||
// Build docker image
|
||||
const builtImage = await Docker.build({ path: actionFolder, dockerfile, baseImage });
|
||||
|
15
src/jest.setup.js
Normal file
15
src/jest.setup.js
Normal file
@ -0,0 +1,15 @@
|
||||
expect.extend({
|
||||
toBeEitherAFunctionOrAnObject(received) {
|
||||
const type = typeof received;
|
||||
|
||||
const pass = ['object', 'function'].includes(type);
|
||||
const message = `Expected a function or an object, received ${type}`;
|
||||
|
||||
return {
|
||||
message,
|
||||
pass,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
jest.mock('./model/input');
|
17
src/model/__mocks__/input.js
Normal file
17
src/model/__mocks__/input.js
Normal file
@ -0,0 +1,17 @@
|
||||
// Import this named export into your test file:
|
||||
import Platform from '../platform';
|
||||
|
||||
export const mockGetFromUser = jest.fn().mockResolvedValue({
|
||||
version: '',
|
||||
targetPlatform: Platform.types.Test,
|
||||
projectPath: '.',
|
||||
buildName: Platform.types.Test,
|
||||
buildsPath: 'build',
|
||||
buildMethod: undefined,
|
||||
buildVersion: '1.3.37',
|
||||
customParameters: '',
|
||||
});
|
||||
|
||||
export default {
|
||||
getFromUser: mockGetFromUser,
|
||||
};
|
5
src/model/__mocks__/versioning.js
Normal file
5
src/model/__mocks__/versioning.js
Normal file
@ -0,0 +1,5 @@
|
||||
export const mockDetermineVersion = jest.fn();
|
||||
|
||||
export default {
|
||||
determineVersion: mockDetermineVersion,
|
||||
};
|
@ -3,27 +3,25 @@ import Platform from './platform';
|
||||
class BuildParameters {
|
||||
static create(parameters) {
|
||||
const {
|
||||
unityVersion,
|
||||
version,
|
||||
targetPlatform,
|
||||
projectPath,
|
||||
buildName,
|
||||
buildsPath,
|
||||
buildMethod,
|
||||
versioning,
|
||||
version,
|
||||
buildVersion,
|
||||
customParameters,
|
||||
} = parameters;
|
||||
|
||||
return {
|
||||
unityVersion,
|
||||
version,
|
||||
platform: targetPlatform,
|
||||
projectPath,
|
||||
buildName,
|
||||
buildPath: `${buildsPath}/${targetPlatform}`,
|
||||
buildFile: this.parseBuildFile(buildName, targetPlatform),
|
||||
buildMethod,
|
||||
versioning,
|
||||
version,
|
||||
buildVersion,
|
||||
customParameters,
|
||||
};
|
||||
}
|
||||
|
@ -4,7 +4,7 @@ import Platform from './platform';
|
||||
describe('BuildParameters', () => {
|
||||
describe('create', () => {
|
||||
const someParameters = {
|
||||
unityVersion: 'someVersion',
|
||||
version: 'someVersion',
|
||||
targetPlatform: 'somePlatform',
|
||||
projectPath: 'path/to/project',
|
||||
buildName: 'someBuildName',
|
||||
@ -18,9 +18,7 @@ describe('BuildParameters', () => {
|
||||
});
|
||||
|
||||
it('returns the version', () => {
|
||||
expect(BuildParameters.create(someParameters).unityVersion).toStrictEqual(
|
||||
someParameters.unityVersion,
|
||||
);
|
||||
expect(BuildParameters.create(someParameters).version).toStrictEqual(someParameters.version);
|
||||
});
|
||||
|
||||
it('returns the platform', () => {
|
||||
|
@ -19,7 +19,7 @@ class Docker {
|
||||
|
||||
static async run(image, parameters, silent = false) {
|
||||
const {
|
||||
unityVersion,
|
||||
version,
|
||||
workspace,
|
||||
platform,
|
||||
projectPath,
|
||||
@ -27,8 +27,7 @@ class Docker {
|
||||
buildPath,
|
||||
buildFile,
|
||||
buildMethod,
|
||||
versioning,
|
||||
version,
|
||||
buildVersion,
|
||||
customParameters,
|
||||
} = parameters;
|
||||
|
||||
@ -40,15 +39,14 @@ class Docker {
|
||||
--env UNITY_EMAIL \
|
||||
--env UNITY_PASSWORD \
|
||||
--env UNITY_SERIAL \
|
||||
--env UNITY_VERSION="${unityVersion}" \
|
||||
--env UNITY_VERSION="${version}" \
|
||||
--env PROJECT_PATH="${projectPath}" \
|
||||
--env BUILD_TARGET="${platform}" \
|
||||
--env BUILD_NAME="${buildName}" \
|
||||
--env BUILD_PATH="${buildPath}" \
|
||||
--env BUILD_FILE="${buildFile}" \
|
||||
--env BUILD_METHOD="${buildMethod}" \
|
||||
--env VERSIONING="${versioning}" \
|
||||
--env VERSION="${version}" \
|
||||
--env VERSION="${buildVersion}" \
|
||||
--env CUSTOM_PARAMETERS="${customParameters}" \
|
||||
--env HOME=/github/home \
|
||||
--env GITHUB_REF \
|
||||
|
8
src/model/error/command-execution-error.js
Normal file
8
src/model/error/command-execution-error.js
Normal file
@ -0,0 +1,8 @@
|
||||
class CommandExecutionError extends Error {
|
||||
constructor(message) {
|
||||
super(message);
|
||||
this.name = 'CommandExecutionError';
|
||||
}
|
||||
}
|
||||
|
||||
export default CommandExecutionError;
|
14
src/model/error/command-execution-error.test.js
Normal file
14
src/model/error/command-execution-error.test.js
Normal file
@ -0,0 +1,14 @@
|
||||
import CommandExecutionError from './command-execution-error';
|
||||
|
||||
describe('CommandExecutionError', () => {
|
||||
it('instantiates', () => {
|
||||
expect(() => new CommandExecutionError()).not.toThrow();
|
||||
});
|
||||
|
||||
test.each([1, 'one', { name: '!' }])('Displays title %s', message => {
|
||||
const error = new CommandExecutionError(message);
|
||||
|
||||
expect(error.name).toStrictEqual('CommandExecutionError');
|
||||
expect(error.message).toStrictEqual(message.toString());
|
||||
});
|
||||
});
|
8
src/model/error/not-implemented-exception.js
Normal file
8
src/model/error/not-implemented-exception.js
Normal file
@ -0,0 +1,8 @@
|
||||
class NotImplementedException extends Error {
|
||||
constructor(message) {
|
||||
super(message);
|
||||
this.name = 'NotImplementedException';
|
||||
}
|
||||
}
|
||||
|
||||
export default NotImplementedException;
|
14
src/model/error/not-implemented-exception.test.js
Normal file
14
src/model/error/not-implemented-exception.test.js
Normal file
@ -0,0 +1,14 @@
|
||||
import NotImplementedException from './not-implemented-exception';
|
||||
|
||||
describe('NotImplementedException', () => {
|
||||
it('instantiates', () => {
|
||||
expect(() => new NotImplementedException()).not.toThrow();
|
||||
});
|
||||
|
||||
test.each([1, 'one', { name: '!' }])('Displays title %s', message => {
|
||||
const error = new NotImplementedException(message);
|
||||
|
||||
expect(error.name).toStrictEqual('NotImplementedException');
|
||||
expect(error.message).toStrictEqual(message.toString());
|
||||
});
|
||||
});
|
@ -7,5 +7,17 @@ import ImageTag from './image-tag';
|
||||
import Platform from './platform';
|
||||
import Project from './project';
|
||||
import Unity from './unity';
|
||||
import Versioning from './versioning';
|
||||
|
||||
export { Action, BuildParameters, Cache, Docker, Input, ImageTag, Platform, Project, Unity };
|
||||
export {
|
||||
Action,
|
||||
BuildParameters,
|
||||
Cache,
|
||||
Docker,
|
||||
Input,
|
||||
ImageTag,
|
||||
Platform,
|
||||
Project,
|
||||
Unity,
|
||||
Versioning,
|
||||
};
|
||||
|
@ -12,6 +12,6 @@ describe('Index', () => {
|
||||
'Project',
|
||||
'Unity',
|
||||
])('exports %s', exportedModule => {
|
||||
expect(typeof Index[exportedModule]).toStrictEqual('function');
|
||||
expect(Index[exportedModule]).toBeEitherAFunctionOrAnObject();
|
||||
});
|
||||
});
|
||||
|
@ -1,43 +1,36 @@
|
||||
import Platform from './platform';
|
||||
import ValidationError from './error/validation-error';
|
||||
import Versioning from './versioning';
|
||||
|
||||
const core = require('@actions/core');
|
||||
|
||||
const versioningStrategies = ['None', 'Semantic', 'Tag', 'Custom'];
|
||||
|
||||
class Input {
|
||||
static getFromUser() {
|
||||
static async getFromUser() {
|
||||
// Input variables specified in workflows using "with" prop.
|
||||
const unityVersion = core.getInput('unityVersion');
|
||||
const version = core.getInput('unityVersion');
|
||||
const targetPlatform = core.getInput('targetPlatform') || Platform.default;
|
||||
const rawProjectPath = core.getInput('projectPath') || '.';
|
||||
const buildName = core.getInput('buildName') || targetPlatform;
|
||||
const buildsPath = core.getInput('buildsPath') || 'build';
|
||||
const buildMethod = core.getInput('buildMethod'); // processed in docker file
|
||||
const versioning = core.getInput('versioning') || 'Semantic';
|
||||
const version = core.getInput('version') || '';
|
||||
const versioningStrategy = core.getInput('versioning') || 'Semantic';
|
||||
const specifiedVersion = core.getInput('version') || '';
|
||||
const customParameters = core.getInput('customParameters') || '';
|
||||
|
||||
// Sanitise input
|
||||
const projectPath = rawProjectPath.replace(/\/$/, '');
|
||||
|
||||
// Validate input
|
||||
if (!versioningStrategies.includes(versioning)) {
|
||||
throw new ValidationError(
|
||||
`Versioning strategy should be one of ${versioningStrategies.join(', ')}.`,
|
||||
);
|
||||
}
|
||||
// Parse input
|
||||
const buildVersion = await Versioning.determineVersion(versioningStrategy, specifiedVersion);
|
||||
|
||||
// Return sanitised input
|
||||
// Return validated input
|
||||
return {
|
||||
unityVersion,
|
||||
version,
|
||||
targetPlatform,
|
||||
projectPath,
|
||||
buildName,
|
||||
buildsPath,
|
||||
buildMethod,
|
||||
versioning,
|
||||
version,
|
||||
buildVersion,
|
||||
customParameters,
|
||||
};
|
||||
}
|
||||
|
@ -1,13 +1,28 @@
|
||||
import { mockDetermineVersion } from './__mocks__/versioning';
|
||||
import Input from './input';
|
||||
|
||||
jest.restoreAllMocks();
|
||||
jest.mock('./versioning');
|
||||
|
||||
beforeEach(() => {
|
||||
mockDetermineVersion.mockClear();
|
||||
});
|
||||
|
||||
describe('Input', () => {
|
||||
describe('getFromUser', () => {
|
||||
it('does not throw', () => {
|
||||
expect(() => Input.getFromUser()).not.toThrow();
|
||||
it('does not throw', async () => {
|
||||
await expect(Input.getFromUser()).resolves.not.toBeNull();
|
||||
});
|
||||
|
||||
it('returns an object', () => {
|
||||
expect(typeof Input.getFromUser()).toStrictEqual('object');
|
||||
it('returns an object', async () => {
|
||||
await expect(typeof (await Input.getFromUser())).toStrictEqual('object');
|
||||
});
|
||||
|
||||
it.skip('calls version generator once', async () => {
|
||||
await Input.getFromUser();
|
||||
|
||||
// Todo - make sure the versioning mock is actually hit after restoreAllMocks is used.
|
||||
expect(mockDetermineVersion).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -4,7 +4,7 @@ import Action from './action';
|
||||
|
||||
class Project {
|
||||
static get relativePath() {
|
||||
const { projectPath } = Input.getFromUser();
|
||||
const projectPath = Input.getFromUser().then(result => result.projectPath);
|
||||
|
||||
return `${projectPath}`;
|
||||
}
|
||||
|
26
src/model/system.js
Normal file
26
src/model/system.js
Normal file
@ -0,0 +1,26 @@
|
||||
import { exec } from '@actions/exec';
|
||||
|
||||
class System {
|
||||
static async run(command, arguments_, options) {
|
||||
let result = '';
|
||||
let error = '';
|
||||
|
||||
const listeners = {
|
||||
stdout: dataBuffer => {
|
||||
result += dataBuffer.toString();
|
||||
},
|
||||
stderr: dataBuffer => {
|
||||
error += dataBuffer.toString();
|
||||
},
|
||||
};
|
||||
|
||||
const exitCode = await exec(command, arguments_, { ...options, listeners });
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(error);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
export default System;
|
187
src/model/versioning.js
Normal file
187
src/model/versioning.js
Normal file
@ -0,0 +1,187 @@
|
||||
import * as core from '@actions/core';
|
||||
import { exec } from '@actions/exec';
|
||||
import NotImplementedException from './error/not-implemented-exception';
|
||||
import ValidationError from './error/validation-error';
|
||||
import System from './system';
|
||||
|
||||
export default class Versioning {
|
||||
static get strategies() {
|
||||
return { None: 'None', Semantic: 'Semantic', Tag: 'Tag', Custom: 'Custom' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the branch name of the (related) branch
|
||||
*/
|
||||
static get branch() {
|
||||
return this.headRef || this.ref.slice(11);
|
||||
}
|
||||
|
||||
/**
|
||||
* For pull requests we can reliably use GITHUB_HEAD_REF
|
||||
*/
|
||||
static get headRef() {
|
||||
return process.env.GITHUB_HEAD_REF;
|
||||
}
|
||||
|
||||
/**
|
||||
* For branches GITHUB_REF will have format `refs/heads/feature-branch-1`
|
||||
*/
|
||||
static get ref() {
|
||||
return process.env.GITHUB_REF;
|
||||
}
|
||||
|
||||
/**
|
||||
* Regex to parse version description into separate fields
|
||||
*/
|
||||
static get descriptionRegex() {
|
||||
return /^v([\d.]+)-(\d+)-g(\w+)-?(\w+)*/g;
|
||||
}
|
||||
|
||||
static async determineVersion(strategy, inputVersion) {
|
||||
// Validate input
|
||||
if (!Object.hasOwnProperty.call(this.strategies, strategy)) {
|
||||
throw new ValidationError(
|
||||
`Versioning strategy should be one of ${Object.values(this.strategies).join(', ')}.`,
|
||||
);
|
||||
}
|
||||
|
||||
let version;
|
||||
switch (strategy) {
|
||||
case this.strategies.None:
|
||||
version = 'none';
|
||||
break;
|
||||
case this.strategies.Custom:
|
||||
version = inputVersion;
|
||||
break;
|
||||
case this.strategies.Semantic:
|
||||
version = await this.generateSemanticVersion();
|
||||
break;
|
||||
case this.strategies.Tag:
|
||||
version = await this.generateTagVersion();
|
||||
break;
|
||||
default:
|
||||
throw new NotImplementedException(`Strategy ${strategy} is not implemented.`);
|
||||
}
|
||||
|
||||
return version;
|
||||
}
|
||||
|
||||
/**
|
||||
* Automatically generates a version based on SemVer out of the box.
|
||||
*
|
||||
* The version works as follows: `<major>.<minor>.<patch>` for example `0.1.2`.
|
||||
*
|
||||
* The latest tag dictates `<major>.<minor>`
|
||||
* The number of commits since that tag dictates`<patch>`.
|
||||
*
|
||||
* @See: https://semver.org/
|
||||
*/
|
||||
static async generateSemanticVersion() {
|
||||
await this.fetchAll();
|
||||
|
||||
if (await this.isDirty()) {
|
||||
throw new Error('Branch is dirty. Refusing to base semantic version on uncommitted changes');
|
||||
}
|
||||
|
||||
if (!(await this.hasAnyVersionTags())) {
|
||||
const version = `0.0.${await this.getTotalNumberOfCommits()}`;
|
||||
core.info(`Generated version ${version} (no version tags found).`);
|
||||
return version;
|
||||
}
|
||||
|
||||
const { tag, commits, hash } = await this.parseSemanticVersion();
|
||||
core.info(`Found semantic version ${tag}.${commits} for ${this.branch}@${hash}`);
|
||||
|
||||
return `${tag}.${commits}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the proper version for unity based on an existing tag.
|
||||
*/
|
||||
static async generateTagVersion() {
|
||||
let tag = await this.getTag();
|
||||
|
||||
if (tag.charAt(0) === 'v') {
|
||||
tag = tag.slice(1);
|
||||
}
|
||||
|
||||
return tag;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the versionDescription into their named parts.
|
||||
*/
|
||||
static async parseSemanticVersion() {
|
||||
const description = await this.getVersionDescription();
|
||||
const [match, tag, commits, hash] = this.descriptionRegex.exec(description);
|
||||
|
||||
if (!match) {
|
||||
throw new Error(`Failed to parse git describe output: "${description}"`);
|
||||
}
|
||||
|
||||
return {
|
||||
match,
|
||||
tag,
|
||||
commits,
|
||||
hash,
|
||||
};
|
||||
}
|
||||
|
||||
static async fetchAll() {
|
||||
await exec('git', ['fetch', '--all']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves information about the branch.
|
||||
*
|
||||
* Format: `v0.12-24-gd2198ab`
|
||||
*
|
||||
* In this format v0.12 is the latest tag, 24 are the number of commits since, and gd2198ab
|
||||
* identifies the current commit.
|
||||
*/
|
||||
static async getVersionDescription() {
|
||||
return System.run('git', ['describe', '--long', '--tags', '--always', `origin/${this.branch}`]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether there are uncommitted changes that are not ignored.
|
||||
*/
|
||||
static async isDirty() {
|
||||
const output = await System.run('git', ['status', '--porcelain']);
|
||||
|
||||
return output !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the tag if there is one pointing at HEAD
|
||||
*/
|
||||
static async getTag() {
|
||||
return System.run('git', ['tag', '--points-at', 'HEAD']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether or not the repository has any version tags yet.
|
||||
*/
|
||||
static async hasAnyVersionTags() {
|
||||
const numberOfVersionCommits = await System.run('git', [
|
||||
'--list',
|
||||
'--merged',
|
||||
'HEAD',
|
||||
'|',
|
||||
'grep v[0-9]*',
|
||||
'|',
|
||||
'wc -l',
|
||||
]);
|
||||
|
||||
return numberOfVersionCommits !== '0';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the total number of commits on head.
|
||||
*/
|
||||
static async getTotalNumberOfCommits() {
|
||||
const numberOfCommitsAsString = await System.run('git', ['rev-list', '--count', 'HEAD']);
|
||||
|
||||
return parseInt(numberOfCommitsAsString, 10);
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user