214 lines
7.0 KiB
Plaintext
214 lines
7.0 KiB
Plaintext
import { EOL } from 'node:os';
|
|
import _ from 'lodash';
|
|
import { execa } from 'execa';
|
|
import matcher from 'wildcard-match';
|
|
import { format, e } from '../../util.js';
|
|
import GitBase from '../GitBase.js';
|
|
import prompts from './prompts.js';
|
|
|
|
const noop = Promise.resolve();
|
|
const invalidPushRepoRe = /^\S+@/;
|
|
const options = { write: false };
|
|
const fixArgs = args => (args ? (typeof args === 'string' ? args.split(' ') : args) : []);
|
|
|
|
const docs = 'https://git.io/release-it-git';
|
|
|
|
const isGitRepo = () =>
|
|
execa('git', ['rev-parse', '--git-dir']).then(
|
|
() => true,
|
|
() => false
|
|
);
|
|
|
|
class Git extends GitBase {
|
|
constructor(...args) {
|
|
super(...args);
|
|
this.registerPrompts(prompts);
|
|
}
|
|
|
|
static async isEnabled(options) {
|
|
return options !== false && (await isGitRepo());
|
|
}
|
|
|
|
async init() {
|
|
if (this.options.requireBranch && !(await this.isRequiredBranch(this.options.requireBranch))) {
|
|
throw e(`Must be on branch ${this.options.requireBranch}`, docs);
|
|
}
|
|
if (this.options.requireCleanWorkingDir && !(await this.isWorkingDirClean())) {
|
|
throw e(`Working dir must be clean.${EOL}Please stage and commit your changes.`, docs);
|
|
}
|
|
|
|
await super.init();
|
|
|
|
const remoteUrl = this.getContext('remoteUrl');
|
|
if (this.options.push && !remoteUrl) {
|
|
throw e(`Could not get remote Git url.${EOL}Please add a remote repository.`, docs);
|
|
}
|
|
if (this.options.requireUpstream && !(await this.hasUpstreamBranch())) {
|
|
throw e(`No upstream configured for current branch.${EOL}Please set an upstream branch.`, docs);
|
|
}
|
|
if (this.options.requireCommits && (await this.getCommitsSinceLatestTag(this.options.commitsPath)) === 0) {
|
|
throw e(`There are no commits since the latest tag.`, docs, this.options.requireCommitsFail);
|
|
}
|
|
}
|
|
|
|
rollback() {
|
|
this.log.info('Rolling back changes...');
|
|
const { tagName } = this.config.getContext();
|
|
const { isCommitted, isTagged } = this.getContext();
|
|
if (isTagged) {
|
|
this.exec(`git tag --delete ${tagName}`);
|
|
}
|
|
this.exec(`git reset --hard HEAD${isCommitted ? '~1' : ''}`);
|
|
}
|
|
|
|
enableRollback() {
|
|
this.rollbackOnce = _.once(this.rollback.bind(this));
|
|
process.on('SIGINT', this.rollbackOnce);
|
|
process.on('exit', this.rollbackOnce);
|
|
}
|
|
|
|
disableRollback() {
|
|
if (this.rollbackOnce) {
|
|
process.removeListener('SIGINT', this.rollbackOnce);
|
|
process.removeListener('exit', this.rollbackOnce);
|
|
}
|
|
}
|
|
|
|
async beforeRelease() {
|
|
if (this.options.commit) {
|
|
if (this.options.requireCleanWorkingDir) {
|
|
this.enableRollback();
|
|
}
|
|
const changeSet = await this.status();
|
|
this.log.preview({ title: 'changeset', text: changeSet });
|
|
await this.stageDir();
|
|
}
|
|
}
|
|
|
|
async release() {
|
|
const { commit, tag, push } = this.options;
|
|
await this.step({ enabled: commit, task: () => this.commit(), label: 'Git commit', prompt: 'commit' });
|
|
await this.step({ enabled: tag, task: () => this.tag(), label: 'Git tag', prompt: 'tag' });
|
|
return !!(await this.step({ enabled: push, task: () => this.push(), label: 'Git push', prompt: 'push' }));
|
|
}
|
|
|
|
async isRequiredBranch() {
|
|
const branch = await this.getBranchName();
|
|
const requiredBranches = _.castArray(this.options.requireBranch);
|
|
return matcher(requiredBranches)(branch);
|
|
}
|
|
|
|
async hasUpstreamBranch() {
|
|
const ref = await this.exec('git symbolic-ref HEAD', { options });
|
|
const branch = await this.exec(`git for-each-ref --format="%(upstream:short)" ${ref}`, { options }).catch(
|
|
() => null
|
|
);
|
|
return Boolean(branch);
|
|
}
|
|
|
|
tagExists(tag) {
|
|
return this.exec(`git show-ref --tags --quiet --verify -- refs/tags/${tag}`, { options }).then(
|
|
() => true,
|
|
() => false
|
|
);
|
|
}
|
|
|
|
isWorkingDirClean() {
|
|
return this.exec('git diff --quiet HEAD', { options }).then(
|
|
() => true,
|
|
() => false
|
|
);
|
|
}
|
|
|
|
async getCommitsSinceLatestTag(commitsPath = '') {
|
|
const latestTagName = await this.getLatestTagName();
|
|
const ref = latestTagName ? `${latestTagName}..HEAD` : 'HEAD';
|
|
return this.exec(`git rev-list ${ref} --count ${commitsPath}`, { options }).then(Number);
|
|
}
|
|
|
|
async getUpstreamArgs(pushRepo) {
|
|
if (pushRepo && !this.isRemoteName(pushRepo)) {
|
|
// Use (only) `pushRepo` if it's configured and looks like a url
|
|
return [pushRepo];
|
|
} else if (!(await this.hasUpstreamBranch())) {
|
|
// Start tracking upstream branch (`pushRepo` is a name if set)
|
|
return ['--set-upstream', pushRepo || 'origin', await this.getBranchName()];
|
|
} else if (pushRepo && !invalidPushRepoRe.test(pushRepo)) {
|
|
return [pushRepo];
|
|
} else {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
stage(file) {
|
|
if (!file || !file.length) return noop;
|
|
const files = _.castArray(file);
|
|
return this.exec(['git', 'add', ...files]).catch(err => {
|
|
this.log.warn(`Could not stage ${files}`);
|
|
this.debug(err);
|
|
});
|
|
}
|
|
|
|
stageDir({ baseDir = '.' } = {}) {
|
|
const { addUntrackedFiles } = this.options;
|
|
return this.exec(['git', 'add', baseDir, addUntrackedFiles ? '--all' : '--update']);
|
|
}
|
|
|
|
reset(file) {
|
|
const files = _.castArray(file);
|
|
return this.exec(['git', 'checkout', 'HEAD', '--', ...files]).catch(err => {
|
|
this.log.warn(`Could not reset ${files}`);
|
|
this.debug(err);
|
|
});
|
|
}
|
|
|
|
status() {
|
|
return this.exec('git status --short --untracked-files=no', { options }).catch(() => null);
|
|
}
|
|
|
|
commit({ message = this.options.commitMessage, args = this.options.commitArgs } = {}) {
|
|
const msg = format(message, this.config.getContext());
|
|
const commitMessageArgs = msg ? ['--message', msg] : [];
|
|
return this.exec(['git', 'commit', ...commitMessageArgs, ...fixArgs(args)]).then(
|
|
() => this.setContext({ isCommitted: true }),
|
|
err => {
|
|
this.debug(err);
|
|
if (/nothing (added )?to commit/.test(err) || /nichts zu committen/.test(err)) {
|
|
this.log.warn('No changes to commit. The latest commit will be tagged.');
|
|
} else {
|
|
throw new Error(err);
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
tag({ name, annotation = this.options.tagAnnotation, args = this.options.tagArgs } = {}) {
|
|
const message = format(annotation, this.config.getContext());
|
|
const tagName = name || this.config.getContext('tagName');
|
|
return this.exec(['git', 'tag', '--annotate', '--message', message, ...fixArgs(args), tagName])
|
|
.then(() => this.setContext({ isTagged: true }))
|
|
.catch(err => {
|
|
const { latestTag, tagName } = this.config.getContext();
|
|
if (/tag '.+' already exists/.test(err) && latestTag === tagName) {
|
|
this.log.warn(`Tag "${tagName}" already exists`);
|
|
} else {
|
|
throw err;
|
|
}
|
|
});
|
|
}
|
|
|
|
async push({ args = this.options.pushArgs } = {}) {
|
|
const { pushRepo } = this.options;
|
|
const upstreamArgs = await this.getUpstreamArgs(pushRepo);
|
|
const push = await this.exec(['git', 'push', ...fixArgs(args), ...upstreamArgs]);
|
|
this.disableRollback();
|
|
return push;
|
|
}
|
|
|
|
afterRelease() {
|
|
this.disableRollback();
|
|
}
|
|
}
|
|
|
|
export default Git;
|