diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000000..0eef3827dd --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +# This file is for unifying the coding style for different editors and IDEs +# editorconfig.org + +root = true + +[*] +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +# Tabs in JS unless otherwise specified +[**.js] +indent_style = space +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/.gitignore b/.gitignore index 04fdf2ec06..c320db082d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ .DS_Store node_modules *.sw[mnpcod] +*.log example/cordova/iOS/www/js/framework example/cordova/iOS/www/js/framework @@ -16,3 +17,4 @@ UserInterfaceState.xcuserstate bower_components/ components/ +tmp diff --git a/.travis.yml b/.travis.yml index 7eb574d80f..9432a27762 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,3 +6,6 @@ before_script: - npm install -g grunt-cli - export DISPLAY=:99.0 - sh -e /etc/init.d/xvfb start + +script: + - ./scripts/travis/ci.sh diff --git a/Gruntfile.js b/Gruntfile.js index 382eaab830..fd1d6b0092 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -1,65 +1,50 @@ +var cp = require('child_process'); +var buildConfig = require('./config/build'); + module.exports = function(grunt) { grunt.initConfig({ + concat: { options: { separator: ';\n' }, dist: { - src: [ - 'js/_license.js', - - // Base - 'js/ionic.js', - - // Utils - 'js/utils/**/*.js', - - // Views - 'js/views/view.js', - - 'js/views/scrollView.js', - - 'js/views/actionSheetView.js', - 'js/views/checkboxView.js', - 'js/views/headerBarView.js', - 'js/views/listView.js', - 'js/views/ListViewScroll.js', - 'js/views/loadingView.js', - 'js/views/modalView.js', - 'js/views/navBarView.js', - 'js/views/popupView.js', - 'js/views/sideMenuView.js', - 'js/views/sliderView.js', - 'js/views/tabBarView.js', - 'js/views/toggleView.js', - - // Controllers - 'js/controllers/viewController.js', - - 'js/controllers/navController.js', - 'js/controllers/sideMenuController.js', - 'js/controllers/tabBarController.js' - - ], + src: buildConfig.files, dest: 'dist/js/ionic.js' }, distAngular: { - src: [ - 'js/_license.js', - 'js/ext/angular/src/ionicAngular.js', - 'js/ext/angular/src/service/**/*.js', - 'js/ext/angular/src/directive/**/*.js' - ], + src: buildConfig.angularFiles, dest: 'dist/js/ionic-angular.js' } }, + jshint: { files: ['Gruntfile.js', 'js/**/*.js', 'test/**/*.js'], options: { jshintrc: '.jshintrc' } }, + + karma: { + options: { + configFile: 'config/karma.conf.js' + }, + single: { + options: { + singleRun: true + } + }, + sauce: { + options: { + singleRun: true, + configFile: 'config/karma-sauce.conf.js' + } + }, + watch: { + } + }, + uglify: { dist: { files: { @@ -71,6 +56,7 @@ module.exports = function(grunt) { preserveComments: 'some' } }, + sass: { dist: { files: { @@ -78,6 +64,7 @@ module.exports = function(grunt) { } } }, + cssmin: { dist: { files: { @@ -85,6 +72,7 @@ module.exports = function(grunt) { } } }, + 'string-replace': { version: { files: { @@ -103,6 +91,16 @@ module.exports = function(grunt) { } } }, + + bump: { + options: { + files: ['package.json'], + commit: false, + createTag: false, + push: false + } + }, + watch: { scripts: { files: ['js/**/*.js', 'ext/**/*.js'], @@ -119,12 +117,14 @@ module.exports = function(grunt) { } } }, + pkg: grunt.file.readJSON('package.json') }); require('load-grunt-tasks')(grunt); grunt.registerTask('default', [ + 'enforce', 'jshint', 'sass', 'cssmin', @@ -132,4 +132,30 @@ module.exports = function(grunt) { 'uglify', 'string-replace' ]); + + grunt.registerMultiTask('karma', 'Run karma', function() { + var done = this.async(); + var options = this.options(); + var config = options.configFile; + var browsers = grunt.option('browsers'); + var singleRun = grunt.option('singleRun') || options.singleRun; + var reporters = grunt.option('reporters'); + + cp.spawn('node', ['node_modules/karma/bin/karma', 'start', config, + browsers ? '--browsers=' + browsers : '', + singleRun ? '--single-run=' + singleRun : '', + reporters ? '--reporters=' + reporters : '' + ], { stdio: 'inherit' }) + .on('exit', function(code) { + if (code) return grunt.fail.warn('Karma test(s) failed. Exit code: ' + code); + done(); + }); + }); + + grunt.registerTask('enforce', 'Install commit message enforce script if it doesn\'t exist', function() { + if (!grunt.file.exists('.git/hooks/commit-msg')) { + grunt.file.copy('scripts/validate-commit-msg.js', '.git/hooks/commit-msg'); + require('fs').chmodSync('.git/hooks/commit-msg', '0755'); + } + }); }; diff --git a/README.md b/README.md index 5b08d20237..24af9fab93 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,25 @@ way is to use Python: + + +## Development + +* `npm install` to setup +* `grunt` to jshint & build +* `grunt karma:single` to test one-time +* `grunt karma:watch` to test and re-run on source change +* Additionally, a commit message validator is installed for this repository when running `grunt`. Read about it [here](https://github.com/ajoslin/conventional-changelog/blob/master/CONVENTIONS.md). + +### Pushing Releases + +(uses AngularJS's bash utils) + +* Run `./scripts/release/finalize-version.sh --action=prepare` to: + - Remove version suffix + - Write new version to package/bower/component.json + - Commit & tag the release +* Run `./scripts/release/finalize-version.sh --action=publish` to: + - Push out new version +* Once new version is pushed out, run `./scripts/release/initialize-new-version.sh` (usage is shown in file), to bump to next version with bump type / version suffix / version name specified. ## LICENSE diff --git a/config/build.js b/config/build.js new file mode 100644 index 0000000000..440b061a2b --- /dev/null +++ b/config/build.js @@ -0,0 +1,42 @@ +module.exports = { + files: [ + 'js/_license.js', + + // Base + 'js/ionic.js', + + // Utils + 'js/utils/**/*.js', + + // Views + 'js/views/view.js', + + 'js/views/scrollView.js', + + 'js/views/actionSheetView.js', + 'js/views/headerBarView.js', + 'js/views/listView.js', + 'js/views/loadingView.js', + 'js/views/modalView.js', + 'js/views/navBarView.js', + 'js/views/popupView.js', + 'js/views/sideMenuView.js', + 'js/views/sliderView.js', + 'js/views/tabBarView.js', + 'js/views/toggleView.js', + + // Controllers + 'js/controllers/viewController.js', + + 'js/controllers/navController.js', + 'js/controllers/sideMenuController.js', + 'js/controllers/tabBarController.js' + + ], + angularFiles: [ + 'js/_license.js', + 'js/ext/angular/src/ionicAngular.js', + 'js/ext/angular/src/service/**/*.js', + 'js/ext/angular/src/directive/**/*.js' + ] +}; diff --git a/config/karma-sauce.conf.js b/config/karma-sauce.conf.js new file mode 100644 index 0000000000..597f27e8da --- /dev/null +++ b/config/karma-sauce.conf.js @@ -0,0 +1,63 @@ + +module.exports = function(config) { + require('./karma.conf.js')(config); + + //username: angular-bootstrap + //password: password + config.set({ + sauceLabs: { + testName: 'ionic', + username: 'ionic-test', + accessKey: '59373b3d-1ee5-43b9-8df4-31107bd21e57', + startConnect: true, + tunnelIdentifier: process.env.TRAVIS_JOB_NUMBER + }, + //Saucelabs mobile emulation (esp android emulator) + //can be really slow sometimes, we need to give it time to connectk + captureTimeout: 60 * 1000, + browserDisconnectTimeout: 60 * 1000, + browserNoActivityTimeout: 60 * 1000, + browserDisconnectTolerance: 2, + transports: ['xhr-polling'], + browsers: [ + // 'sauce_ios', + 'sauce_safari', + // 'sauce_android', + 'sauce_chrome', + 'sauce_firefox', + // 'sauce_ie9', + // 'sauce_ie10', + // 'sauce_ie11' + ], + customLaunchers: { + 'sauce_ios': { + base: 'SauceLabs', + platform: 'OS X 10.9', + browserName: 'iphone', + version: '7' + }, + 'sauce_safari': { + base: 'SauceLabs', + browserName: 'safari', + platform: 'OS X 10.9', + version: '7' + }, + 'sauce_android': { + base: 'SauceLabs', + platform: 'Linux', + browserName: 'android', + version: '4.0' + }, + 'sauce_chrome': { + base: 'SauceLabs', + browserName: 'chrome' + }, + 'sauce_firefox': { + base: 'SauceLabs', + platform: 'Linux', + browserName: 'firefox', + version: '26' + } + }, + }); +}; diff --git a/ionic.conf.js b/config/karma.conf.js similarity index 77% rename from ionic.conf.js rename to config/karma.conf.js index 60f4f89121..b8280f002d 100644 --- a/ionic.conf.js +++ b/config/karma.conf.js @@ -1,11 +1,10 @@ -// Karma configuration -// Generated on Wed Sep 04 2013 08:59:26 GMT-0500 (CDT) +var buildConfig = require('./build'); module.exports = function(config) { config.set({ // base path, that will be used to resolve files and exclude - basePath: '', + basePath: '../', // frameworks to use @@ -16,61 +15,54 @@ module.exports = function(config) { files: [ // Include jQuery only for testing convience (lots of DOM checking for unit tests on directives) 'http://codeorigin.jquery.com/jquery-1.10.2.min.js', - - 'dist/js/ionic.js', 'dist/js/angular/angular.js', 'dist/js/angular/angular-animate.js', 'dist/js/angular/angular-resource.js', 'dist/js/angular/angular-mocks.js', 'dist/js/angular/angular-sanitize.js', 'dist/js/angular-ui/angular-ui-router.js', - 'dist/js/ionic-angular.js', - + ] + .concat(buildConfig.files, buildConfig.angularFiles, [ 'test/**/*.js', - 'js/ext/angular/test/**/*.js' - ], + ]), + // list of files to exclude + exclude: [ + ], // test results reporter to use // possible values: 'dots', 'progress', 'junit', 'growl', 'coverage' reporters: ['progress'], - // web server port port: 9876, - // enable / disable colors in the output (reporters and logs) colors: true, - // level of logging // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG logLevel: config.LOG_INFO, - // enable / disable watching file and executing tests whenever any file changes autoWatch: true, + // If browser does not capture in given timeout [ms], kill it + captureTimeout: 60000, + + // Continuous Integration mode + // if true, it capture browsers, run tests and exit + singleRun: false, // Start these browsers, currently available: // - Chrome // - ChromeCanary // - Firefox - // - Opera - // - Safari (only Mac) + // - Opera (has to be installed with `npm install karma-opera-launcher`) + // - Safari (only Mac; has to be installed with `npm install karma-safari-launcher`) // - PhantomJS - // - IE (only Windows) - browsers: ['Chrome'], - - - // If browser does not capture in given timeout [ms], kill it - captureTimeout: 60000, - - - // Continuous Integration mode - // if true, it capture browsers, run tests and exit - singleRun: false + // - IE (only Windows; has to be installed with `npm install karma-ie-launcher`) + browsers: ['Chrome'] }); }; diff --git a/package.json b/package.json index a3323d1ec5..81e16fe663 100644 --- a/package.json +++ b/package.json @@ -2,9 +2,11 @@ "name": "ionic", "private": false, "version": "0.9.23-alpha", + "codename": "maine-coon", "devDependencies": { "karma": "~0.10", "grunt": "~0.4.1", + "grunt-bump": "0.0.13", "grunt-contrib-watch": "~0.5.3", "grunt-contrib-cssmin": "~0.7.0", "grunt-contrib-copy": "~0.4.1", @@ -17,9 +19,6 @@ "karma-chrome-launcher": "~0.1.0", "load-grunt-tasks": "~0.2.0" }, - "scripts": { - "test": "./node_modules/.bin/karma start ionic.conf.js --single-run --browsers PhantomJS" - }, "licenses": [ { "type": "MIT" diff --git a/scripts/release/finalize-version.sh b/scripts/release/finalize-version.sh new file mode 100755 index 0000000000..6f4b590e51 --- /dev/null +++ b/scripts/release/finalize-version.sh @@ -0,0 +1,44 @@ +#!/bin/bash + +# Inspired by AngularJS's finalize-version script + +ARG_DEFS=( + "--action=(prepare|publish)" +) + +function prepare { + cd ../.. + + # Remove suffix + replaceJsonProp "package.json" "version" "(.*)?-[a-zA-Z]+" "\2" + + VERSION=$(readJsonProp "package.json" "version") + CODENAME=$(readJsonProp "package.json" "codename") + + replaceJsonProp "bower.json" "version" ".*" "$VERSION" + replaceJsonProp "component.json" "version" ".*" "$VERSION" + + git add package.json bower.json component.json + git commit -m "chore(release): v$VERSION" + git tag -m "v$VERSION" v$VERSION + + echo "--" + echo "-- Version is now $VERSION, codename $CODENAME." + echo "-- Release commit & tag created." + echo "-- When ready to push, run ./scripts/finalize-version.sh --action=publish" + echo "--" +} + +function publish { + cd ../.. + + VERSION=$(readJsonProp "package.json" "version") + BRANCH=$(git rev-parse --abbrev-ref HEAD) + + git push origin $BRANCH + git push origin v$VERSION + + cd $SCRIPT_DIR +} + +source $(dirname $0)/../utils.inc diff --git a/scripts/release/initialize-new-version.sh b/scripts/release/initialize-new-version.sh new file mode 100755 index 0000000000..412abf7977 --- /dev/null +++ b/scripts/release/initialize-new-version.sh @@ -0,0 +1,30 @@ +#!/bin/bash + +# Adapted from Angular's bump script + +echo "#########################################################" +echo "## Increment version, add suffix, and set version name ##" +echo "#########################################################" + +ARG_DEFS=( + "--version-type=(patch|minor|major)" + "--version-name=(.+)" + "--version-suffix=(.+)" +) + +function run { + cd ../.. + + grunt bump:$VERSION_TYPE + replaceJsonProp "package.json" "version" "(.*)" "\2-"$VERSION_SUFFIX + replaceJsonProp "package.json" "codename" ".*" "$VERSION_NAME" + + VERSION=$(readJsonProp "package.json" "version") + + git add package.json + git commit -m "chore(release): start v$VERSION" + + git push origin master +} + +source $(dirname $0)/../utils.inc diff --git a/scripts/travis/ci.sh b/scripts/travis/ci.sh new file mode 100755 index 0000000000..16d3b2d534 --- /dev/null +++ b/scripts/travis/ci.sh @@ -0,0 +1,34 @@ +#!/bin/bash + +# Task that runs every time CI server is pushed to + +ARG_DEFS=( +) + +# function init { + # for pushing docs/cdn + # git config --global user.name 'Ionic Roboman' + # git config --global user.email ionic.roboman@drifty.com +# } + +function run { + cd ../.. + + # Build / JSHint + grunt jshint + # TODO Check for things like iit / ddescribe / merge conflicts / leftover console.log + + # Do a cursory test with PhantomJS + # just so we can quickly fail if some tests fail + grunt karma:single --browsers=PhantomJS --reporters=dots + + # Do sauce test with all browsers (takes longer) + # Saucelabs settings need more tweaking before it becomes stable (sometimes it fails) + # grunt karma:sauce --reporters=dots + + # TODO Build docs + # TODO Push to CDN +} + +source $(dirname $0)/../utils.inc + diff --git a/scripts/utils.inc b/scripts/utils.inc new file mode 100644 index 0000000000..43119fa8e1 --- /dev/null +++ b/scripts/utils.inc @@ -0,0 +1,281 @@ +# bash utils from angularjs + +# This file provides: +# - a default control flow +# * initializes the environment +# * able to mock "git push" in your script and in all sub scripts +# * call a function in your script based on the arguments +# - named argument parsing and automatic generation of the "usage" for your script +# - intercepting "git push" in your script and all sub scripts +# - utility functions +# +# Usage: +# - define the variable ARGS_DEF (see below) with the arguments for your script +# - include this file using `source utils.inc` at the end of your script. +# +# Default control flow: +# 0. Set the current directory to the directory of the script. By this +# the script can be called from anywhere. +# 1. Parse the named arguments +# 2. If the parameter "git_push_dryrun" is set, all calls the `git push` in this script +# or in child scripts will be intercepted so that the `--dry-run` and `--porcelain` is added +# to show what the push would do but not actually do it. +# 3. If the parameter "verbose" is set, the `-x` flag will be set in bash. +# 4. The function "init" will be called if it exists +# 5. If the parameter "action" is set, it will call the function with the name of that parameter. +# Otherwise the function "run" will be called. +# +# Named Argument Parsing: +# - The variable ARGS_DEF defines the valid command arguments +# * Required args syntax: --paramName=paramRegex +# * Optional args syntax: [--paramName=paramRegex] +# * e.g. ARG_DEFS=("--required_param=(.+)" "[--optional_param=(.+)]") +# - Checks that: +# * all arguments match to an entry in ARGS_DEF +# * all required arguments are present +# * all arguments match their regex +# - Afterwards, every paramter value will be stored in a variable +# with the name of the parameter in upper case (with dash converted to underscore). +# +# Special arguments that are always available: +# - "--action=.*": This parameter will be used to dispatch to a function with that name when the +# script is started +# - "--git_push_dryrun=true": This will intercept all calls to `git push` in this script +# or in child scripts so that the `--dry-run` and `--porcelain` is added +# to show what the push would do but not actually do it. +# - "--verbose=true": This will set the `-x` flag in bash so that all calls will be logged +# +# Utility functions: +# - readJsonProp +# - replaceJsonProp +# - resolveDir +# - getVar +# - serVar +# - isFunction + +# always stop on errors +set -e + +function usage { + echo "Usage: ${0} ${ARG_DEFS[@]}" + exit 1 +} + + +function parseArgs { + local REQUIRED_ARG_NAMES=() + + # -- helper functions + function varName { + # everything to upper case and dash to underscore + echo ${1//-/_} | tr '[:lower:]' '[:upper:]' + } + + function readArgDefs { + local ARG_DEF + local AD_OPTIONAL + local AD_NAME + local AD_RE + + # -- helper functions + function parseArgDef { + local ARG_DEF_REGEX="(\[?)--([^=]+)=(.*)" + if [[ ! $1 =~ $ARG_DEF_REGEX ]]; then + echo "Internal error: arg def has wrong format: $ARG_DEF" + exit 1 + fi + AD_OPTIONAL="${BASH_REMATCH[1]}" + AD_NAME="${BASH_REMATCH[2]}" + AD_RE="${BASH_REMATCH[3]}" + if [[ $AD_OPTIONAL ]]; then + # Remove last bracket for optional args. + # Can't put this into the ARG_DEF_REGEX somehow... + AD_RE=${AD_RE%?} + fi + } + + # -- run + for ARG_DEF in "${ARG_DEFS[@]}" + do + parseArgDef $ARG_DEF + + local AD_NAME_UPPER=$(varName $AD_NAME) + setVar "${AD_NAME_UPPER}_OPTIONAL" "$AD_OPTIONAL" + setVar "${AD_NAME_UPPER}_RE" "$AD_RE" + if [[ ! $AD_OPTIONAL ]]; then + REQUIRED_ARG_NAMES+=($AD_NAME) + fi + done + } + + function readAndValidateArgs { + local ARG_NAME + local ARG_VALUE + local ARG_NAME_UPPER + + # -- helper functions + function parseArg { + local ARG_REGEX="--([^=]+)=?(.*)" + + if [[ ! $1 =~ $ARG_REGEX ]]; then + echo "Can't parse argument $i" + usage + fi + + ARG_NAME="${BASH_REMATCH[1]}" + ARG_VALUE="${BASH_REMATCH[2]}" + ARG_NAME_UPPER=$(varName $ARG_NAME) + } + + function validateArg { + local AD_RE=$(getVar ${ARG_NAME_UPPER}_RE) + + if [[ ! $AD_RE ]]; then + echo "Unknown option: $ARG_NAME" + usage + fi + + if [[ ! $ARG_VALUE =~ ^${AD_RE}$ ]]; then + echo "Wrong format: $ARG_NAME" + usage; + fi + + # validate that the "action" option points to a valid function + if [[ $ARG_NAME == "action" ]] && ! isFunction $ARG_VALUE; then + echo "No action $ARG_VALUE defined in this script" + usage; + fi + } + + # -- run + for i in "$@" + do + parseArg $i + validateArg + setVar "${ARG_NAME_UPPER}" "$ARG_VALUE" + done + } + + function checkMissingArgs { + local ARG_NAME + for ARG_NAME in "${REQUIRED_ARG_NAMES[@]}" + do + ARG_VALUE=$(getVar $(varName $ARG_NAME)) + + if [[ ! $ARG_VALUE ]]; then + echo "Missing: $ARG_NAME" + usage; + fi + done + } + + # -- run + readArgDefs + readAndValidateArgs "$@" + checkMissingArgs + +} + +# getVar(varName) +function getVar { + echo ${!1} +} + +# setVar(varName, varValue) +function setVar { + eval "$1=\"$2\"" +} + +# isFunction(name) +# - to be used in an if, so return 0 if successful and 1 if not! +function isFunction { + if [[ $(type -t $1) == "function" ]]; then + return 0 + else + return 1 + fi +} + +# readJsonProp(jsonFile, property) +# - restriction: property needs to be on an own line! +function readJsonProp { + echo $(sed -En 's/.*"'$2'"[ ]*:[ ]*"(.*)".*/\1/p' $1) +} + +# replaceJsonProp(jsonFile, propertyRegex, valueRegex, replacePattern) +# - note: propertyRegex will be automatically placed into a +# capturing group! -> all other groups start at index 2! +function replaceJsonProp { + replaceInFile $1 '"('$2')"[ ]*:[ ]*"'$3'"' '"\1": "'$4'"' +} + +# replaceInFile(file, findPattern, replacePattern) +function replaceInFile { + sed -i .tmp -E "s/$2/$3/" $1 + rm $1.tmp +} + +# resolveDir(relativeDir) +# - resolves a directory relative to the current script +function resolveDir { + echo $(cd $SCRIPT_DIR; cd $1; pwd) +} + +function git_push_dryrun_proxy { + echo "## git push dryrun proxy enabled!" + export ORIGIN_GIT=$(which git) + + function git { + local ARGS=("$@") + local RC + if [[ $1 == "push" ]]; then + ARGS+=("--dry-run" "--porcelain") + echo "####### START GIT PUSH DRYRUN #######" + echo "${ARGS[@]}" + fi + if [[ $1 == "commit" ]]; then + echo "${ARGS[@]}" + fi + $ORIGIN_GIT "${ARGS[@]}" + RC=$? + if [[ $1 == "push" ]]; then + echo "####### END GIT PUSH DRYRUN #######" + fi + return $RC + } + + export -f git +} + +function main { + # normalize the working dir to the directory of the script + cd $(dirname $0);SCRIPT_DIR=$(pwd) + + ARG_DEFS+=("[--git-push-dryrun=(true|false)]" "[--verbose=(true|false)]") + parseArgs "$@" + + # --git_push_dryrun argument + if [[ $GIT_PUSH_DRYRUN == "true" ]]; then + git_push_dryrun_proxy + fi + + # --verbose argument + if [[ $VERBOSE == "true" ]]; then + set -x + fi + + if isFunction init; then + init "$@" + fi + + # jump to the function denoted by the --action argument, + # otherwise call the "run" function + if [[ $ACTION ]]; then + $ACTION "$@" + else + run "$@" + fi +} + + +main "$@" diff --git a/scripts/validate-commit-msg.js b/scripts/validate-commit-msg.js new file mode 100644 index 0000000000..5b922dafff --- /dev/null +++ b/scripts/validate-commit-msg.js @@ -0,0 +1,110 @@ +#!/usr/bin/env node + +/* + * https://github.com/angular/angular.js/blob/master/validate-commit-msg.js + */ + +/** + * Git COMMIT-MSG hook for validating commit message + * See https://docs.google.com/document/d/1rk04jEuGfk9kYzfqCuOlPTSJw3hEDZJTBN5E5f1SALo/edit + * + * Installation: + * >> cd + * >> ln -s ../../validate-commit-msg.js .git/hooks/commit-msg + */ +var fs = require('fs'); +var util = require('util'); + + +var MAX_LENGTH = 100; +var PATTERN = /^(?:fixup!\s*)?(\w*)(\(([\w\$\.\-\*/]*)\))?\: (.*)$/; +var IGNORED = /^WIP\:/; +var TYPES = { + feat: true, + fix: true, + docs: true, + style: true, + refactor: true, + perf: true, + test: true, + chore: true, + revert: true +}; + + +var error = function() { + // gitx does not display it + // http://gitx.lighthouseapp.com/projects/17830/tickets/294-feature-display-hook-error-message-when-hook-fails + // https://groups.google.com/group/gitx/browse_thread/thread/a03bcab60844b812 + console.error('INVALID COMMIT MSG: ' + util.format.apply(null, arguments)); +}; + + +var validateMessage = function(message) { + var isValid = true; + + if (IGNORED.test(message)) { + console.log('Commit message validation ignored.'); + return true; + } + + if (message.length > MAX_LENGTH) { + error('is longer than %d characters !', MAX_LENGTH); + isValid = false; + } + + var match = PATTERN.exec(message); + + if (!match) { + error('does not match "(): " ! was: ' + message); + return false; + } + + var type = match[1]; + var scope = match[3]; + var subject = match[4]; + + if (!TYPES.hasOwnProperty(type)) { + error('"%s" is not allowed type !', type); + return false; + } + + // Some more ideas, do want anything like this ? + // - allow only specific scopes (eg. fix(docs) should not be allowed ? + // - auto correct the type to lower case ? + // - auto correct first letter of the subject to lower case ? + // - auto add empty line after subject ? + // - auto remove empty () ? + // - auto correct typos in type ? + // - store incorrect messages, so that we can learn + + return isValid; +}; + + +var firstLineFromBuffer = function(buffer) { + return buffer.toString().split('\n').shift(); +}; + + + +// publish for testing +exports.validateMessage = validateMessage; + +// hacky start if not run by jasmine :-D +if (process.argv.join('').indexOf('jasmine-node') === -1) { + var commitMsgFile = process.argv[2]; + var incorrectLogFile = commitMsgFile.replace('COMMIT_EDITMSG', 'logs/incorrect-commit-msgs'); + + fs.readFile(commitMsgFile, function(err, buffer) { + var msg = firstLineFromBuffer(buffer); + + if (!validateMessage(msg)) { + fs.appendFile(incorrectLogFile, msg + '\n', function() { + process.exit(1); + }); + } else { + process.exit(0); + } + }); +}