import * as fs from 'fs' ; import * as path from 'path' ; import * as cp from 'child_process' ; import { JPCMConfig } from './config' ; import { debug , log } from './log' ; interface MessageInfo { originalMessage : string ; cleanMessage : string ; hasAnyText : boolean ; hasUserText : boolean ; hasVerboseText : boolean ; } const conventionalCommitRegExp = /^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\([a-z- ]+\)!?)?: ([\w \S]+)$/g ; const gitVerboseStatusSeparator = '------------------------ >8 ------------------------' ; function getMsgFilePath ( gitRoot , index = 0 ) { debug ( 'getMsgFilePath' ) ; if ( gitRoot . length > 0 ) { if ( ! path . isAbsolute ( gitRoot ) ) { const cwd = process . cwd ( ) ; log ( `Resolving .git path from ${ cwd }` ) ; gitRoot = path . resolve ( cwd , gitRoot ) ; } if ( ! gitRoot . includes ( '.git' ) ) { gitRoot = path . join ( gitRoot , '.git' ) ; } return path . join ( gitRoot , 'COMMIT_EDITMSG' ) ; } if ( process . env . HUSKY_GIT_PARAMS === undefined ) { const messageFilePath = process . argv . find ( ( arg ) => arg . includes ( '.git' ) ) ; if ( messageFilePath ) { return messageFilePath ; } else { throw new Error ( `You are using Husky 5. Please add $1 to jira-pre-commit-msg's parameters.` ) ; } } const gitParams = process . env . HUSKY_GIT_PARAMS || '' ; if ( ! gitParams ) { throw new Error ( `The process.env.HUSKY_GIT_PARAMS isn't set. Is supported Husky version installed?` ) ; } return gitParams . split ( ' ' ) [ index ] ; } function escapeReplacement ( str ) { return str . replace ( /[$]/ , '$$$$' ) ; } function replaceMessageByPattern ( jiraTicket , message , pattern ) { const result = pattern . replace ( '$J' , escapeReplacement ( jiraTicket ) ) . replace ( '$M' , escapeReplacement ( message ) ) ; debug ( `Replacing message: ${ result }` ) ; return result ; } function getMessageInfo ( message , config ) { debug ( `Original commit message: ${ message }` ) ; const messageSections = message . split ( gitVerboseStatusSeparator ) [ 0 ] ; const lines = messageSections . trim ( ) . split ( '\n' ) . map ( ( line ) => line . trimLeft ( ) ) . filter ( ( line ) => ! line . startsWith ( config . commentChar ) ) ; const cleanMessage = lines . join ( '\n' ) . trim ( ) ; debug ( `Clean commit message (${ cleanMessage . length }): ${ cleanMessage }` ) ; return { originalMessage : message , cleanMessage : cleanMessage , hasAnyText : message . length !== 0 , hasUserText : cleanMessage . length !== 0 , hasVerboseText : message . includes ( gitVerboseStatusSeparator ) , } ; } function findFirstLineToInsert ( lines , config ) { let firstNotEmptyLine = - 1 ; for ( let i = 0 ; i < lines . length ; ++ i ) { const line = lines [ i ] ; if ( line === gitVerboseStatusSeparator ) { break ; } if ( line . startsWith ( config . commentChar ) ) { continue ; } if ( firstNotEmptyLine === - 1 ) { firstNotEmptyLine = i ; break ; } } return firstNotEmptyLine ; } function insertJiraTicketIntoMessage ( messageInfo , jiraTicket , config ) { const message = messageInfo . originalMessage ; const lines = message . split ( '\n' ) . map ( ( line ) => line . trimLeft ( ) ) ; if ( ! messageInfo . hasUserText ) { debug ( `User didn't write the message. Allow empty commit is ${ String ( config . allowEmptyCommitMessage ) }` ) ; const preparedMessage = replaceMessageByPattern ( jiraTicket , '' , config . messagePattern ) ; if ( messageInfo . hasAnyText ) { const insertedMessage = config . allowEmptyCommitMessage ? preparedMessage : `# ${ preparedMessage }\n` + '# JIRA prepare commit msg > ' + 'Please uncomment the line above if you want to insert JIRA ticket into commit message' ; lines . unshift ( insertedMessage ) ; } else { if ( config . allowEmptyCommitMessage ) { lines . unshift ( preparedMessage ) ; } else { debug ( `Commit message is empty. Skipping...` ) ; } } } else { const firstLineToInsert = findFirstLineToInsert ( lines , config ) ; debug ( `First line to insert is: ${ firstLineToInsert > - 1 ? lines [ firstLineToInsert ] : '' } (${ firstLineToInsert })` ) ; if ( firstLineToInsert !== - 1 ) { const line = lines [ firstLineToInsert ] ; if ( config . isConventionalCommit ) { debug ( `Finding conventional commit in: ${ line }` ) ; conventionalCommitRegExp . lastIndex = - 1 ; const [ match , type , scope , msg ] = conventionalCommitRegExp . exec ( line ) ?? [ ] ; if ( match ) { debug ( `Conventional commit message: ${ match }` ) ; if ( ! msg . includes ( jiraTicket ) ) { const replacedMessage = replaceMessageByPattern ( jiraTicket , msg , config . messagePattern ) ; lines [ firstLineToInsert ] = `${ type }${ scope || '' }: ${ replacedMessage }` ; } } } else if ( ! line . includes ( jiraTicket ) ) { lines [ firstLineToInsert ] = replaceMessageByPattern ( jiraTicket , line || '' , config . messagePattern ) ; } } if ( lines . every ( ( line ) => ! line . includes ( jiraTicket ) ) ) { lines [ 0 ] = replaceMessageByPattern ( jiraTicket , lines [ 0 ] || '' , config . messagePattern ) ; } } return lines . join ( '\n' ) ; } export type GitRevParseResult = { prefix : string ; gitCommonDir : string ; } ; export function gitRevParse ( cwd = process . cwd ( ) ) { const { status , stderr , stdout } = cp . spawnSync ( 'git' , [ 'rev-parse' , '--show-prefix' , '--git-common-dir' ] , { cwd } ) ; if ( status !== 0 ) { throw new Error ( stderr . toString ( ) ) ; } const [ prefix , gitCommonDir ] = stdout . toString ( ) . split ( '\n' ) . map ( ( s ) => s . trim ( ) ) . map ( ( s ) => s . replace ( /\\\\/ , '/' ) ) ; return { prefix , gitCommonDir } ; } export function getRoot ( ) { debug ( 'getRoot' ) ; const cwd = process . cwd ( ) ; const { gitCommonDir } = gitRevParse ( cwd ) ; if ( gitCommonDir === '--git-common-dir' ) { throw new Error ( 'Husky requires Git >= 2.13.0, please upgrade Git' ) ; } return path . resolve ( cwd , gitCommonDir ) ; } export async function getBranchName ( gitRoot ) { debug ( 'gitBranchName' ) ; return new Promise ( ( resolve , reject ) => { cp . exec ( `git --git-dir="${ gitRoot }" symbolic-ref --short HEAD` , { encoding : 'utf-8' } , ( err , stdout , stderr ) => { if ( err ) { return reject ( err ) ; } if ( stderr ) { return reject ( new Error ( String ( stderr ) ) ) ; } resolve ( String ( stdout ) . trim ( ) ) ; } ) ; } ) ; } export function getJiraTicket ( branchName , config ) { debug ( 'getJiraTicket' ) ; const jiraIdPattern = new RegExp ( config . jiraTicketPattern , 'i' ) ; const matched = jiraIdPattern . exec ( branchName ) ; const jiraTicket = matched && matched [ 0 ] ; if ( ! jiraTicket ) { throw new Error ( 'The JIRA ticket ID not found' ) ; } return jiraTicket . toUpperCase ( ) ; } export function writeJiraTicket ( jiraTicket , config ) { debug ( 'writeJiraTicket' ) ; const messageFilePath = getMsgFilePath ( config . gitRoot ) ; let message ; try { message = fs . readFileSync ( messageFilePath , { encoding : 'utf-8' } ) ; } catch ( ex ) { throw new Error ( `Unable to read the file "${ messageFilePath }".` ) ; } const messageInfo = getMessageInfo ( message , config ) ; const messageWithJiraTicket = insertJiraTicketIntoMessage ( messageInfo , jiraTicket , config ) ; debug ( messageWithJiraTicket ) ; try { fs . writeFileSync ( messageFilePath , messageWithJiraTicket , { encoding : 'utf-8' } ) ; } catch ( ex ) { throw new Error ( `Unable to write the file "${ messageFilePath }".` ) ; } }