/*jslint node:true*/
'use strict';
/**
* This is a bootstrapper for dependency
* injection. It is used so we can require or
* mock the modules outside of this file and
* pass them in at runtime. This makes testing
* MUCH simpler as we can mock objects in
* tests and pass them in.
*
* @param {object} exec - child_process.exec
* @param {object} path - The path module.
* @returns {object} P4 - The P4 module constructor.
*/
module.exports = function(exec,path){
/**
* @callback
*/
/**
* Takes output from p4 fstat and parses it to an object.
* @param {string} stats - The output
* @returns {object} the stats object, may be instanceof Array
*/
function parseStats(stats) {
var statsArr = [];
stats = stats.split('\n\n');
var validStat = stats.every(function(stat){
var statsObj = {}, idx;
if(stat === '' || stats === '\n'){
// Return true to continue the loop
return true;
}
var valid = stat.split('\n').every(function(line){
var level = 0
, key = ''
, value = ''
, obj = [];
// Line has 3 dots and a space at the beginning, pull that off and determine the "indentation" level
do{
line = line.slice(4);
level++;
} while(line.indexOf('... ') === 0);
obj = line.split(' ');
obj[1] = obj.slice(1).join(' ');
key = obj[0];
value = obj[1];
if(!key){
// Continue the loop, ignore it
return true;
}
if(value === ''){
value = true;
}
if(level === 1){
statsObj[key] = value;
} else if(level === 2){
if(!statsObj.other){
statsObj.other = [];
}
if(key === 'otherOpen'){
// We are at the end of the others
if(statsObj.other.length !== Number(value)){
// Return false here because the input is invalid
return false;
}
return true;
}
// Get the array index of the key
idx = key.match(/other[a-zA-Z]+(\d+)$/)[1];
// Now remove `other` from the beginning and the idx from the end
key = key.slice(5,-idx.length);
if(!statsObj.other[idx]){
// Extend the array one more
statsObj.other.push({});
}
if(statsObj.other.length !== Number(idx)+1){
// Invalid output, not ordered properly!
return false;
}
statsObj.other[idx][key] = value;
} else {
// level is not 1 or 2, weird output
return false;
}
// Continue
return true;
});
if(valid){
statsArr.push(statsObj);
}
// Continue if last was valid
return valid;
});
if(!validStat){
return new Error('Invalid fstat output!');
}
if(statsArr.length === 1){
return statsArr[0];
}
return statsArr;
}
/**
* @constructor
*/
function P4(){
if(!(this instanceof P4)){
return new P4();
}
this.cwd = __dirname;
this.options = {};
}
/**
* This function changes the cwd property
* and behaves much like unix cd. It resolves
* the new path based on the current CWD, and
* handles absolute paths too.
* Supports chaining.
*
* @param {string} dir - The directory to cd
* @returns {object} this
*/
P4.prototype.cd = function(dir){
this.cwd = path.resolve(this.cwd,dir);
// Allow chaining by returning this
// i.e. p4.cd('dir').edit('file')
// or p4.cd('path').cd('to').cd('dir')
return this;
};
/**
* Set options for the exec context.
* Supports all optinos supported by child_process.exec.
* Supports chaining.
*
* @param {object} opts - The options object
* @returns {object} this
*/
P4.prototype.setOpts = function(opts){
var self = this;
Object.keys(opts).forEach(function(key){
if(key === 'cwd'){
// Don't allow changing cwd via setOpts...
return;
}
self.options[key] = opts[key];
});
return this;
};
/**
* Run a command, used internally but public.
* @param {string} command - The command to run
* @param {array|string} args - The arguments for the command
* @param {function} done - The done callback
* @returns {object} the child process object
*/
P4.prototype.runCommand = function(command, args, done) {
if(typeof args === 'function') {
done = args;
args = '';
}
if(args instanceof Array) {
args = args.join(' ');
}
this.options.cwd = this.cwd;
this.options.env = this.options.env || {};
this.options.env.PWD = this.cwd;
return exec('p4 ' + command + ' ' + (args || ''), this.options, function(err, stdOut, stdErr) {
if(err) {
return done(err);
}
// When we run p4 fstat *, it will say no such file(s) on dirs
// fix this by reducing the error string and omitting matching lines
// when calling join() on an empty array, it returns an empty string which is *falsy*
stdErr = stdErr.split('\n').reduce(function(pval,line){
if(!/no such file/.test(line)){
// Only include if it doesn't match our test
pval.push(line);
}
return pval;
},[]).join('\n');
// This could more easily be done with this:
// stdErr = _.reject(stdErr.split('\n'),function(line){return /no such file/.test(line)}).join('\n')
// but learning reduce is a _good thing_ (TM) and the algorithm is arguably more descriptive and
// self-documenting when using reduce
if(stdErr) {
return done(new Error(stdErr));
}
return done(null, stdOut);
});
};
/**
* Runs an arbitrary shell command.
* @warning DOES NOT SHELL ESCAPE ANYTHING
* @param {string} command - The command to run
* @param {array|string} args - The arguments to pass
* @param {function} done - The done callback
* @returns {object} the child process object
*/
P4.prototype.runShellCommand = function(command, args, done) {
if(typeof args === 'function') {
done = args;
args = '';
}
if(args instanceof Array) {
args = args.join(' ');
}
return exec(command + ' ' + (args || ''), {cwd:this.cwd}, function(err, stdOut, stdErr) {
if(err) {
return done(err);
}
if(stdErr) {
return done(new Error(stdErr),stdOut);
}
return done(null, stdOut);
});
};
/**
* Calls p4 edit on the filepath passed.
* @param {string} filepath - The filepath, can be absolute or relative to cwd
* @param {function} done - The done callback
* @returns {object} the child process object
*/
P4.prototype.edit = function(filepath, done) {
return this.runCommand('edit', filepath, done);
};
/**
* Calls p4 add on the filepath passed.
* @param {string} filepath - The filepath, can be absolute or relative to cwd
* @param {function} done - The done callback
* @returns {object} the child process object
*/
P4.prototype.add = function(filepath, done) {
return this.runCommand('add', filepath, done);
};
/**
* Calls p4 edit on the filepath. If an error is thrown, calls p4 add on the filepath.
* @param {string} filepath - The filepath, can be absolute or relative to cwd
* @param {function} done - The done callback
* @returns {object} the child process object
*/
P4.prototype.smartEdit = function(filepath, done) {
var self = this;
return this.edit(filepath, function(err, out) {
if(!err) {
return done(null, out);
}
return self.add(filepath, done);
});
};
/**
* Calls p4 revert -a. Ignores filepath arg.
* @param {string} filepath - The filepath, ignored
* @param {function} done - The done callback
* @returns {object} the child process object
*/
P4.prototype.revertUnchanged = function(filepath, done) {
if(!done){
done = filepath;
filepath = null;
}
filepath = filepath || '';
return this.runCommand('revert', '-a', done);
};
/**
* Calls p4 fstat *. If filepath is passed, it will first cd to that path.
* @param {string} filepath - The filepath
* @param {function} done - The done callback
* @returns {object} the child process object
*/
P4.prototype.statDir = function(filepath, done) {
if(!done){
done = filepath;
filepath = null;
}
if(filepath){
this.cd(filepath);
}
return this.runCommand('fstat','*', function(err,out){
if(err){
return done(err);
}
var output = parseStats(out);
if(output instanceof Error){
return done(output);
}
return done(null,output);
});
};
/**
* Calls `p4 fstat ...`. If filepath is passed, it will first cd to that path.
* @param {string} filepath - The filepath
* @param {function} done - The done callback
* @returns {object} the child process object
*/
P4.prototype.recursiveStatDir = function(filepath, done) {
if(!done){
done = filepath;
filepath = null;
}
if(filepath){
this.cd(filepath);
}
return this.runCommand('fstat','...', function(err,out){
if(err){
return done(err);
}
var output = parseStats(out);
if(output instanceof Error){
return done(output);
}
return done(null,output);
});
};
/**
* Calls `p4 fstat` on `path.basename(filepath)`.
* If filepath is not passed, it will call the cb with an error.
* @param {string} filepath - The filepath
* @param {function} done - The done callback
* @returns {object} the child process object
*/
P4.prototype.stat = function(filepath, done) {
if(!done){
done = filepath;
filepath = null;
}
if(!filepath){
return done(new Error('Please pass a file to stat!'));
}
this.cd(path.dirname(filepath));
return this.runCommand('fstat', path.basename(filepath), function(err,out){
if(err){
return done(err);
}
var output = parseStats(out);
if(output instanceof Error){
return done(output);
}
return done(null,output);
});
};
/**
* Calls `p4 have` on `filepath`.
* If filepath is not passed, it will call the cb with an error.
* @param {string} filepath - The filepath
* @param {function} done - The done callback
* @returns {object} the child process object
*/
P4.prototype.have = function(filepath, done) {
if(!done){
done = filepath;
filepath = null;
}
if(!filepath){
return done(new Error('Please pass a file to inspect!'));
}
return this.stat(filepath, function(err,stats){
if(err){
return done(err);
}
return done(null,stats.haveRev);
});
};
/**
* Calls `p4 revert` on `filepath`.
* If filepath is not passed, it will call the cb with an error.
* @param {string} filepath - The filepath
* @param {function} done - The done callback
* @returns {object} the child process object
*/
P4.prototype.revert = function(filepath, done) {
if(!done){
done = filepath;
filepath = null;
}
if(!filepath){
return done(new Error('Please pass a file to revert!'));
}
return this.runCommand('revert', filepath, done);
};
/**
* Calls `p4 submit` on `filepath`.
* @param {string} filepath - The filepath
* @param {string} desc - The changelist description
* @param {function} done - The done callback
* @returns {object} the child process object
*/
P4.prototype.submit = function(filepath, desc, done) {
return this.runCommand('submit', ['-d', '"' + desc + '"',filepath], done);
};
/**
* Calls `p4 sync` on `filepath`.
* If filepath is not passed, calls sync in cwd.
* @param {string} filepath - The filepath
* @param {function} done - The done callback
* @returns {object} the child process object
*/
P4.prototype.sync = function(filepath, done) {
if(!done){
done = filepath;
filepath = null;
}
return this.runCommand('sync', filepath, done);
};
/**
* Calls `p4 sync *` in `filepath`.
* If filepath is not passed, calls `p4 sync *` in cwd.
* @param {string} filepath - The filepath
* @param {function} done - The done callback
* @returns {object} the child process object
*/
P4.prototype.syncDir = function(filepath, done) {
if(!done){
done = filepath;
filepath = null;
}
if(filepath){
this.cd(filepath);
}
return this.runCommand('sync', '*', done);
};
/**
* Calls `p4 sync ...` in `filepath`.
* If filepath is not passed, calls `p4 sync ...` in cwd.
* @param {string} filepath - The filepath
* @param {function} done - The done callback
* @returns {object} the child process object
*/
P4.prototype.recursiveSyncDir = function(filepath, done) {
if(!done){
done = filepath;
filepath = null;
}
if(filepath){
this.cd(filepath);
}
return this.runCommand('sync', '...', done);
};
/**
* Calls `p4 login`.
* Important, this happens in the cwd so whatever P4CONFIG is found will be used.
* @param {string} username - The username
* @param {string} password - The password
* @param {function} done - The done callback
* @returns {object} the child process object
*/
P4.prototype.login = function(username, password, done) {
return this.runShellCommand('echo "' + password + '" | p4 login -u "' + username + '"', done);
};
/**
* Returns the cwd.
* @returns {string} The current cwd
*/
P4.prototype.pwd = function(){
return this.cwd;
};
return P4;
};