diff --git a/lib/index.js b/lib/index.js index c2bfaec..03c5f76 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,6 +1,201 @@ +var path = require('path'); + +var Q = require('q'); +var wrench = require('wrench'); +var _ = require('lodash'); +var grunt = require('grunt'); + +var pkg = require('../package.json'); +var git = require('./git'); + +var copy = require('./util').copy; + +function getCacheDir() { + return '.gh-pages'; +} + +function getRemoteUrl(dir, remote) { + var repo; + return git(['config', '--get', 'remote.' + remote + '.url'], dir) + .progress(function(chunk) { + repo = String(chunk).split(/[\n\r]/).shift(); + }) + .then(function() { + if (repo) { + return Q.resolve(repo); + } else { + return Q.reject(new Error( + 'Failed to get repo URL from options or current directory.')); + } + }) + .fail(function(err) { + return Q.reject(new Error( + 'Failed to get remote.origin.url (task must either be run in a ' + + 'git repository with a configured origin remote or must be ' + + 'configured with the "repo" option).')); + }); +} + +function getRepo(options) { + if (options.repo) { + return Q.resolve(options.repo); + } else { + return getRemoteUrl(process.cwd(), 'origin'); + } +} -/** - * Generate promises for spawned git commands. - */ -exports.git = require('./git'); +exports.publish = function publish(config, done) { + var defaults = { + add: false, + git: 'git', + clone: getCacheDir(), + dotfiles: false, + branch: 'gh-pages', + remote: 'origin', + base: process.cwd(), + src: '**/*', + only: '.', + push: true, + message: 'Updates', + silent: false, + logger: function(){} + }; + + // override defaults with any task options + var options = _.extend({}, defaults, config); + + if (!grunt.file.isDir(options.base)) { + return done(new Error('The "base" option must be an existing directory')); + } + + var files = grunt.file.expand({ + filter: 'isFile', + cwd: options.base, + dot: options.dotfiles + }, options.src); + + if (!Array.isArray(files) || files.length === 0) { + return done(new Error('Files must be provided in the "src" property.')); + } + + var only = grunt.file.expand({cwd: options.base}, options.only); + + function log(message) { + if (!options.silent) { + options.logger(message); + } + } + + git.exe(options.git); + + var repoUrl; + getRepo(options) + .then(function(repo) { + repoUrl = repo; + log('Cloning ' + repo + ' into ' + options.clone); + return git.clone(repo, options.clone, options.branch, options); + }) + .then(function() { + return getRemoteUrl(options.clone, options.remote) + .then(function(url) { + if (url !== repoUrl) { + var message = 'Remote url mismatch. Got "' + url + '" ' + + 'but expected "' + repoUrl + '" in ' + options.clone + + '. If you have changed your "repo" option, try ' + + 'running `grunt gh-pages-clean` first.'; + return Q.reject(new Error(message)); + } else { + return Q.resolve(); + } + }); + }) + .then(function() { + // only required if someone mucks with the checkout between builds + log('Cleaning'); + return git.clean(options.clone); + }) + .then(function() { + log('Fetching ' + options.remote); + return git.fetch(options.remote, options.clone); + }) + .then(function() { + log('Checking out ' + options.remote + '/' + + options.branch); + return git.checkout(options.remote, options.branch, + options.clone); + }) + .then(function() { + if (!options.add) { + log('Removing files'); + return git.rm(only.join(' '), options.clone); + } else { + return Q.resolve(); + } + }) + .then(function() { + log('Copying files'); + return copy(files, options.base, options.clone); + }) + .then(function() { + log('Adding all'); + return git.add('.', options.clone); + }) + .then(function() { + if (options.user) { + return git(['config', 'user.email', options.user.email], + options.clone) + .then(function() { + return git(['config', 'user.name', options.user.name], + options.clone); + }); + } else { + return Q.resolve(); + } + }) + .then(function() { + log('Committing'); + return git.commit(options.message, options.clone); + }) + .then(function() { + if (options.tag) { + log('Tagging'); + var deferred = Q.defer(); + git.tag(options.tag, options.clone) + .then(function() { + return deferred.resolve(); + }) + .fail(function(error) { + // tagging failed probably because this tag alredy exists + log('Tagging failed, continuing'); + options.logger(error); + return deferred.resolve(); + }); + return deferred.promise; + } else { + return Q.resolve(); + } + }) + .then(function() { + if (options.push) { + log('Pushing'); + return git.push(options.remote, options.branch, + options.clone); + } else { + return Q.resolve(); + } + }) + .then(function() { + done(); + }, function(error) { + if (options.silent) { + error = new Error( + 'Unspecified error (run without silent option for detail)'); + } + done(error); + }); +}; + +exports.clean = function clean() { + wrench.rmdirSyncRecursive(getCacheDir(), true); +}; diff --git a/lib/util.js b/lib/util.js new file mode 100644 index 0000000..c3742dd --- /dev/null +++ b/lib/util.js @@ -0,0 +1,164 @@ +var path = require('path'); + +var async = require('async'); +var fs = require('graceful-fs'); +var Q = require('q'); + + +/** + * Generate a list of unique directory paths given a list of file paths. + * @param {Array.} files List of file paths. + * @return {Array.} List of directory paths. + */ +var uniqueDirs = exports.uniqueDirs = function(files) { + var dirs = {}; + files.forEach(function(filepath) { + var parts = path.dirname(filepath).split(path.sep); + var partial = parts[0]; + dirs[partial] = true; + for (var i = 1, ii = parts.length; i < ii; ++i) { + partial = path.join(partial, parts[i]); + dirs[partial] = true; + } + }); + return Object.keys(dirs); +}; + + +/** + * Sort function for paths. Sorter paths come first. Paths of equal length are + * sorted alphanumerically in path segment order. + * @param {string} a First path. + * @param {string} b Second path. + * @return {number} Comparison. + */ +var byShortPath = exports.byShortPath = function(a, b) { + var aParts = a.split(path.sep); + var bParts = b.split(path.sep); + var aLength = aParts.length; + var bLength = bParts.length; + var cmp = 0; + if (aLength < bLength) { + cmp = -1; + } else if (aLength > bLength) { + cmp = 1; + } else { + var aPart, bPart; + for (var i = 0; i < aLength; ++i) { + aPart = aParts[i]; + bPart = bParts[i]; + if (aPart < bPart) { + cmp = -1; + break; + } else if (aPart > bPart) { + cmp = 1; + break; + } + } + } + return cmp; +}; + + +/** + * Generate a list of directories to create given a list of file paths. + * @param {Array.} files List of file paths. + * @return {Array.} List of directory paths ordered by path length. + */ +var dirsToCreate = exports.dirsToCreate = function(files) { + return uniqueDirs(files).sort(byShortPath); +}; + + +/** + * Copy a file. + * @param {Object} obj Object with src and dest properties. + * @param {function(Error)} callback Callback + */ +var copyFile = exports.copyFile = function(obj, callback) { + var called = false; + function done(err) { + if (!called) { + called = true; + callback(err); + } + } + + var read = fs.createReadStream(obj.src); + read.on('error', function(err) { + done(err); + }); + + var write = fs.createWriteStream(obj.dest); + write.on('error', function(err) { + done(err); + }); + write.on('close', function(ex) { + done(); + }); + + read.pipe(write); +}; + + +/** + * Make directory, ignoring errors if directory already exists. + * @param {string} path Directory path. + * @param {function(Error)} callback Callback. + */ +function makeDir(path, callback) { + fs.mkdir(path, function(err) { + if (err) { + // check if directory exists + fs.stat(path, function(err2, stat) { + if (err2 || !stat.isDirectory()) { + callback(err); + } else { + callback(); + } + }); + } else { + callback(); + } + }); +} + + +/** + * Copy a list of files. + * @param {Array.} files Files to copy. + * @param {string} base Base directory. + * @param {string} dest Destination directory. + * @return {Promise} A promise. + */ +var copy = exports.copy = function(files, base, dest) { + var deferred = Q.defer(); + + var pairs = []; + var destFiles = []; + files.forEach(function(file) { + var src = path.resolve(base, file); + var relative = path.relative(base, src); + var target = path.join(dest, relative); + pairs.push({ + src: src, + dest: target + }); + destFiles.push(target); + }); + + async.eachSeries(dirsToCreate(destFiles), makeDir, function(err) { + if (err) { + return deferred.reject(err); + } + async.each(pairs, copyFile, function(err) { + if (err) { + return deferred.reject(err); + } else { + return deferred.resolve(); + } + }); + }); + + return deferred.promise; +}; diff --git a/package.json b/package.json index 83d641f..bf0d5a1 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,12 @@ }, "dependencies": { "q": "~1.0.1", - "q-io": "~1.11.0" + "q-io": "~1.11.0", + "graceful-fs": "2.0.1", + "async": "0.2.9", + "wrench": "1.5.1", + "lodash": "~2.4.1", + "grunt": "~0.4.5" }, "devDependencies": { "glob": "~3.2.9",