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;