arozwalak
2/26/2014 - 7:12 PM

Sublime: SVN or GIT Build System

Sublime: SVN or GIT Build System

/*
// Use this in your SublimeText build script file:
{
	// Use node to run the build_hyper.js script, and pass it the name of the project,
	// which should be the same as the name of the SVN or GIT repository and project directory (local to the current working directory)
	// on your computer.
	//
	// For example:
	//     svn://test_repo
	// or:
	//     git://test_repo
	//
	// If first argument is not starting with `git://` or `svn://`, it will be assummed that it is a path to the local repo directory,
	// and Builder will try to check if it's an SVN or GIT repository by checking for `.svn` and `.git` subdirectories.
	//
	// Last argument(s) can be URL or scripts that should be called after the commit. It may be used to make server update
	// site from the SVN or GIT. You can use "[REPO_NAME]" inside that URL, to pass repo name to the URL, e.g.,
	//     http://www.example.com/updateFromSVN/[REPO_NAME]
	// and/or:
	//     node://some_script_in_the_working_dir.js
	// and/or:
	//     python://some_script_in_the_working_dir.js
	// etc... supported are: node, python, php and ruby
	//
	// There is also special command supported to upload files changed between revisions to some server:
	// sftp://IP_OR_DOMAIN_OF_SERVER/PATH_TO_ROOT_DIRECTORY/?ignorePrefix=trunk/publish/
	// ignorePrefix defines which part of the project path should be stripped and only files inside that prefix will be uploaded.
	//
	// Here it is the same directory as the one which keeps all the repo directories and all the project files.
	"cmd": ["node", "$project_path\\sublime_build_hyper.js", "${project_name/.sublime-project//}", "http://your.site.com/update.php?repo=[REPO_NAME]"],

	// This should not be needed, but it's here just in case you may need it (if you are getting encoding errors).
	//"encoding": "iso-8859-2",
	// We do not need to open shell window.
	"shell": false,

	// This should be set to the directory which contains all the repo directories.
	// Here it is the same directory which contains project files (*.sublime-project).
	"working_dir": "$project_path\\"

	// This allows specyfying different versions of builds or additional tasks.
	"variants": [
		// Here we declare a variant which will ask user about revisions to deploy to target server.
		{
			"cmd": ["node", "sublime_build_hyper.js", "${project_name/.sublime-project//}", "sftp://user@dev.example.com/myApplication/"],
			"name": "Deploy to DEV"
		}
	]
}
*/

var querystring = require('querystring');
var emitter = require('events').EventEmitter;
var crypto = require('crypto');
var spawn = require('child_process').spawn;
var https = require('https');
var http = require('http');
var exec = require('child_process').exec;
var util = require('util');
var path = require('path');
var url = require('url');
var fs = require('fs');

var SUBLIME = (process.platform === 'win32' ? 'C:\\Program Files\\Sublime Text 2\\sublime_text.exe' : 'subl');

/**
 *	Sublime builder in node.js.
 *	Repository name is a name of the directory, relative to the current working directory.
 *	If it does not start with either "svn://" or "git://", Builder will try to check which repository type it is,
 *	by checking for `.svn` and `.git` subdirectory.
 *
 *	@constructor
 *
 *	@param {string} repository - name of the repository directory, relative to the current working directory
 */
var Builder = function(repository){
	"use strict";

	// Inherit EventEmitter.
	emitter.call(this);

	/**
	 *	@private
	 */
	var self = this;

	/**
	 *	@private
	 */
	var REPO_DIR = repository;
	var REPO_TYPE = false;

	/**
	 *	@private
	 */
	var MODULES = {};

	/**
	 *	@private
	 */
	var PROTOCOLS = {};

	/**
	 *	This callback type is called `execCallback` and is displayed as a global symbol.
	 *
	 *	@callback execCallback
	 *	@param {Error} error
	 *	@param {Buffer} stdout
	 *	@param {Buffer} stderr
	 */

	/**
	 *	Call URL. It can be either HTTP(S) or custom, like node://, php://, etc...
	 *
	 *	@param {string} targetURL
	 *	@param {execCallback} callback
	 */
	this.callURL = function(targetURL, callback) {
		var urlObject = url.parse(targetURL.replace(/\[REPO_NAME\]/g, REPO_DIR));

		if (PROTOCOLS.hasOwnProperty(urlObject.protocol) && PROTOCOLS[urlObject.protocol]) {
			PROTOCOLS[urlObject.protocol](urlObject, callback);
		}
		else {
			callback(-1, '', 'ERROR: protocol '+urlObject.protocol+' is not supported!'+"\n");
		}
	};

	/**
	 *	Spawn process and callback when done.
	 *	command example:
	 *
	 *		['svn', 'log', '-l', '1', 'myrepository']
	 *
	 *	@param {Array} command - array of command line arguments, including command as a first item in the array
	 *	@param {String} directory - if not empty, change working directory to it while running the command
	 *	@param {execCallback} callback
	 */
	this.exec = function(command, directory, callback){
		console.log('Calling '+command.join(' ')+(directory ? ' inside '+directory : ''));
		var cwd = process.cwd();
		if (directory) {
			process.chdir(directory);
		}
		var child = spawn(command.shift(), command);
		var stdout = '';
		var stderr = '';
		child.stdout.on('data', function(data){
			stdout += data;
		});
		child.stderr.on('data', function(data){
			stderr += data;
		});
		child.on('close', function(code){
			if (directory) {
				process.chdir(cwd);
			}
			callback(code, stdout, stderr);
		});
		child.on('error', function(error){
			console.log(error);
		});
	};

	/**
	 *	Require a module, installing it when needed.
	 *
	 *	@param {string} module - name of the module
	 *	@param {execCallback} callback
	 */
	this.require = function(module, callback){
		if (MODULES.hasOwnProperty(module)) {
			callback(false, '', '');
			return;
		}

		MODULES[module] = false;

		// Require uses current file's directory for resolution, not current working directory,
		// so we switch working directory temporarily.
		var cwd = process.cwd();
		process.chdir(__dirname);
		try {
			MODULES[module] = require(module);
			process.chdir(cwd);
			callback(false, '', '');
		}
		catch (e) {
			console.log(e);
			self.exec([(process.platform === 'win32' ? 'npm.cmd' : 'npm'), 'install', module], false, function(error, stdout, stderr){
				if (!error) {
					try {
						MODULES[module] = require(module);
					}
					catch (e) {
						error = e.Error;
						MODULES[module] = false;
					}
				}
				process.chdir(cwd);
				callback(error, stdout, stderr);
			});
		}
	};

	/**
	 *	Recursive read of directory to list all the files.
	 *	From: https://gist.github.com/DelvarWorld/825583
	 *
	 *	@param {string} start - directory path
	 *	@param {function} callback(error, found)
	 */
	this.readDir = function(start, callback) {
		// Use lstat to resolve symlink if we are passed a symlink
		fs.lstat(start, function(err, stat) {
			if (err) {
				return callback(err);
			}

			var found = {dirs: [], files: []};
			var total = 0;
			var processed = 0;

			function isDir(abspath) {
				fs.stat(abspath, function(err, stat) {
					if (stat.isDirectory()) {
						found.dirs.push(abspath);
						// If we found a directory, recurse!
						self.readDir(abspath, function(err, data) {
							found.dirs = found.dirs.concat(data.dirs);
							found.files = found.files.concat(data.files);
							if (++processed == total) {
								callback(null, found);
							}
						});
					} else {
						found.files.push(abspath);
						if (++processed == total) {
							callback(null, found);
						}
					}
				});
			}

			// Read through all the files in this directory
			if (stat.isDirectory()) {
				fs.readdir(start, function (err, files) {
					total = files.length;
					for (var x=0, l=files.length; x<l; x++) {
						isDir(path.join(start, files[x]));
					}
					if (total === 0) {
						callback(null, found);
					}
				});
			} else {
				return callback(new Error("path: " + start + " is not a directory"));
			}
		});
	};

	/**
	 *	Delete files.
	 *
	 *	@param {Array} fileList - list of files to be deleted
	 *	@param {execCallback} callback
	 */
	this.filesDelete = function(fileList, callback){
		var _filesDelete;
		var files = fileList.slice();

		_filesDelete = function(error) {
			if (files.length < 1) {
				callback();
				return;
			}
			else if (error) {
				callback(true, '', error);
				return;
			}

			fs.unlink(files.shift(), _filesDelete);
		};

		_filesDelete();
	};

	/**
	 *	Read through the fileList, find all directories and recursively add all subdirectories and their files to the fileList.
	 *
	 *	@param {Array} fileList - list of files to add files to
	 *	@param {execCallback} callback
	 */
	this.filesAddToList = function(fileList, callback){
		var _filesAddToList;
		var files = fileList.slice();

		_filesAddToList = function() {
			if (files.length < 1) {
				// Make sure that the files and directories are unique.
				var temp = {};
				for (var i = 0; i < fileList.length; i++) {
					temp[fileList[i]] = 1;
				}
				fileList = Object.keys(temp);
				fileList.sort();
				callback();
				return;
			}

			var file = files.shift();
			var stat = fs.statSync(file);
			if (stat.isDirectory()) {
				// Make sure that there are no spaces in any of the directory names.
				if (file.match(/\s+/)) {
					callback(true, '', 'ERROR: there is a whitespace in one of the file or directory names: "'+file+'"');
					return;
				}

				self.readDir(file, function(error, found){
					if (!error && found) {
						fileList.push.apply(fileList, found.dirs);
						fileList.push.apply(fileList, found.files);
					}
					_filesAddToList();
				});
			}
			else {
				_filesAddToList();
			}
		};

		_filesAddToList();
	};

	/**
	 *	Show user a text to edit, run callback with edited text.
	 *
	 *	@param {string} data - text presented to the user to edit
	 *	@param {integer} startAtLine - line number at which editor cursor will start by default
	 *	@param {integer} startAtChar - character position at which editor will start by default
	 *	@param {function} callback - function(Buffer dataEditedByUser) will be called after user closes editor
	 *	@returns {string} filename - file in which user data is saved temporarily
	 */
	this.getDataFromUser = function(data, startAtLine, startAtChar, callback){
		console.log('Getting data from the user');

		if (isNaN(startAtLine)) {
			startAtLine = 0;
		}

		if (isNaN(startAtChar)) {
			startAtChar = 0;
		}

		data = "#\n# Close the window (CTRL+SHIFT+W) and confirm the file save (ENTER) after you are done with the configuration.\n#\n" + data;
		startAtLine += 3;

		var tempfilename = Date.now() + '_' + crypto.randomBytes(4).readUInt32LE(0) + '.txt';
		fs.writeFileSync(tempfilename, data);

		var child = spawn(SUBLIME, ['-w', '-s', '-n', tempfilename+':'+startAtLine+':'+startAtChar]);

		child.on('close', function(){
			var newData = fs.readFileSync(tempfilename);
			if (!newData) {
				console.log('ERROR: could not read data.');
			}
			fs.unlinkSync(tempfilename);

			callback(newData);
		});

		return tempfilename;
	};

	/**
	 *	Call for SVN update, then ask user for commit message and changelist, then commit to SVN.
	 *
	 *	@param {execCallback} callback
	 */
	this.commit = function(callback){
		// Get list of changes.
		self.repo.status(function(error, stdout, stderr){
			var changes = self.repo.parseChanges(stdout);
			console.log(changes);

			// Update from SVN to be up-todate with changes commited in the meantime.
			self.repo.update(function(error, stdout, stderr){
				console.log(stdout+(error ? stderr : ''));

				// Check if there is anything to do, and return early if not.
				if (!changes || (changes.A.length < 1 && changes.M.length < 1 && changes.D.length < 1)) {
					callback(false, 'Nothing to commit', '');
					return;
				}

				// Delete files that were restored by update, but should be deleted according to last status check.
				var deletedFiles = [];
				if (changes && changes.D.length > 0) {
					changes.D.forEach(function(value, index){
						if (stdout.indexOf('Restored \''+value) > -1) {
							deletedFiles.push(value);
						}
					});
				}

				self.filesDelete(deletedFiles, function(){
					self.filesAddToList(changes.A, function(error, stdout, stderr){
						if (error) {
							console.log(stdout+stderr);
							return;
						}
						console.log(changes);
						// Continue with the commit process.
						self.repo.lastCommit(function(error, stdout, stderr){
							var message = self.repo.parseMessage(stdout);
							var data = "# Enter you commit message below. DO NOT CHANGE OR REMOVE THIS LINE!\n\n" + message + "\n\n# Commit following changes (DO NOT CHANGE OR REMOVE THIS LINE!):\n\n";

							changes.A.forEach(function(value, index){
								data += 'A '+value+"\n";
							});

							changes.M.forEach(function(value, index){
								data += 'M '+value+"\n";
							});

							changes.D.forEach(function(value, index){
								data += 'D '+value+"\n";
							});

							// Ask user for commit message and list of files and/or directories to be committed.
							self.getDataFromUser(data, 3, 0, function(data){
								var info = data.toString();

								var list = info.match(/# Commit following changes [\w\W]+/);
								message = info.replace(list[0], '').replace(/(^|[\r\n]+)#\s[^\r\n]*/g, '').replace(/^[\r\n\s]+|[\r\n\s]+$/g, '');
								list = list[0].match(/(^|[\r\n]+)([AMD]) ([^\r\n]+)/g);

								// Check if there is anything to do, and return early if not.
								if (!list || list.length < 1) {
									callback(false, 'Nothing to commit', '');
									return;
								}

								// Check if there is commit message.
								if (!message) {
									callback(false, 'No commit message', '');
									return;
								}

								var newChanges = {A: [], M: [], D: []};
								list.forEach(function(value, index){
									var temp = value.replace(/^[\r\n\s]+|[\r\n\s]+$/g, '');
									newChanges[temp.charAt(0)].push(temp.substring(2));
								});

								// Check if there is anything to do, and return early if not.
								if (newChanges.A.length < 1 && newChanges.M.length < 1 && newChanges.D.length < 1) {
									callback(false, 'Nothing to commit', '');
									return;
								}

								self.repo.save(newChanges, message, callback);
							});
						});
					});
				});
			});
		});
	};

	/**
	 *	Repository Manager is used to work with SVN or GIT.
	 *
	 *	@typedef {Object} RepoManager
	 *	@property {Function} parseChanges
	 *	@property {Function} parseMessage
	 *	@property {Function} parseRevision
	 *	@property {Function} status
	 *	@property {Function} diff
	 *	@property {Function} add
	 *	@property {Function} delete
	 *	@property {Function} update
	 *	@property {Function} commit
	 *	@property {Function} lastCommit
	 *	@property {Function} lastRevision
	 *	@property {Function} save
	 */

	/**
	 *	@member {RepoManager} repo manager that will be used
	 */
	this.repo = null;

	/**
	 *	@member {RepoManager} svn manager
	 */
	this.svn = {};

	/**
	 *	RepoChanges is used to keep list of changed files and/or directories.
	 *
	 *	@typedef {Object} RepoChanges
	 *	@property {Array} A - list of added files and/or directories
	 *	@property {Array} M - list of modified files
	 *	@property {Array} D - list of deleted files and/or directories
	 */

	/**
	 *	Parse list of changed files from SVN.
	 *	This function is synchronous!
	 *
	 *	@param {string} stdout - data returned from `svn status` or `svn diff --summarize` commands
	 *
	 *	@returns {RepoChanges}
	 */
	this.svn.parseChanges = function(stdout){
		var match = null;
		var regex = /([\?!DAM])\s+([^\r\n]+)/g;
		var result = {A: [], M: [], D: []};
		while ((match = regex.exec(stdout))) {
			if (match[1] === '!') {
				match[1] = 'D';
			}
			else if (match[1] === '?') {
				match[1] = 'A';
			}

			result[match[1]].push(match[2]);
		}
		return result;
	};

	/**
	 *	Parse commit message from SVN log stdout.
	 *	This function is synchronous!
	 *
	 *	@param {string} stdout - data returned from `svn log` command
	 *
	 *	@returns {string}
	 */
	this.svn.parseMessage = function(stdout){
		return stdout.replace(/^([-]+[\r\n]+)(r\d+[^\r\n]+[\r\n]+)/, '').replace(/([\r\n]*)([-]+[\r\n]+)$/, '');
	};

	/**
	 *	Parse revision number from SVN info format.
	 *	This function is synchronous!
	 *
	 *	@param {string} stdout - data returned from `svn log` command
	 *
	 *	@returns {string}
	 */
	this.svn.parseRevision = function(stdout){
		var revision = stdout.match(/[\r\n\s]+Revision:\s*(\d+)/i);
		if (revision) {
			revision = revision[1].replace(/[^\d]+/g, '');
		}
		else {
			revision = false;
		}
		return revision;
	};

	/**
	 *	Get SVN status and pass it to the callback function.
	 *
	 *	@param {execCallback} callback
	 */
	this.svn.status = function(callback){
		exec('svn status '+REPO_DIR, callback);
	};

	/**
	 *	Get SVN diff between two revisions and pass output (list of changed files) to the callback function.
	 *
	 *	@param {integer} start - start revision number
	 *	@param {integer} end - end revision number
	 *	@param {execCallback} callback
	 */
	this.svn.diff = function(start, end, callback){
		exec('svn diff '+REPO_DIR+' -r'+start+':'+end+' --summarize', callback);
	};

	/**
	 *	Add files to SVN.
	 *
	 *	@param {Array} fileList - list of files to be added to the SVN
	 *	@param {execCallback} callback
	 */
	this.svn.add = function(fileList, callback){
		var _svnadd;
		var files = fileList.slice();

		_svnadd = function(){
			if (files.length < 1) {
				callback();
				return;
			}

			self.exec(['svn', 'add', files.shift()], false, _svnadd);
		};

		_svnadd();
	};

	/**
	 *	Delete files from SVN.
	 *
	 *	@param {Array} fileList - list of files to be deleted from the SVN
	 *	@param {execCallback} callback
	 */
	this.svn.delete = function(fileList, callback){
		var _svndelete;
		var files = fileList.slice();

		_svndelete = function() {
			if (files.length < 1) {
				callback();
				return;
			}

			self.exec(['svn', 'delete', files.shift()], false, _svndelete);
		};

		_svndelete();
	};

	/**
	 *	Add files to the SVN changelist to be commited at a later time.
	 *
	 *	@param {string} changeset - name of the changeset
	 *	@param {Array} fileList - list of files to be deleted from the SVN
	 *	@param {execCallback} callback
	 */
	this.svn.changeList = function(changeset, fileList, callback){
		var _svnchangelist;
		var files = fileList.slice();

		var directories = [];

		_svnchangelist = function() {
			if (files.length < 1) {
				if (directories.length > 0) {
					self.svn.add(directories, callback);
				}
				else {
					callback();
				}
				return;
			}

			var file = files.shift();
			fs.lstat(file, function(err, stat){
				// Changelist do not work for directories.
				if (!err && stat.isDirectory()) {
					directories.push(file);
					_svnchangelist();
				}
				else {
					self.exec(['svn', 'changelist', changeset, file], false, _svnchangelist);
				}
			});
		};

		_svnchangelist();
	};

	/**
	 *	Remove SVN changelist.
	 *
	 *	@param {string} changeset - name of the changeset
	 *	@param {execCallback} callback
	 */
	this.svn.removeChangeList = function(changeset, callback){
		exec('svn changelist --remove --recursive --cl '+changeset+' '+REPO_DIR, function(){
			callback();
		});
	};

	/**
	 *	Update from SVN and pass output to the callback function.
	 *
	 *	@param {execCallback} callback
	 */
	this.svn.update = function(callback){
		exec('svn up '+REPO_DIR, callback);
	};

	/**
	 *	Commit to SVN.
	 *
	 *	@param {string} message - commit message
	 *	@param {string} changeset - name of the changeset
	 *	@param {execCallback} callback
	 */
	this.svn.commit = function(message, changeset, callback){
		var command = ['svn', 'commit', '-m', message];

		if (changeset) {
			command.push('--changelist');
			command.push(changeset);
		}

		command.push(REPO_DIR);

		self.exec(command, false, callback);
	};

	/**
	 *	Get last commit info from SVN and pass output to the callback function.
	 *
	 *	@param {execCallback} callback
	 */
	this.svn.lastCommit = function(callback){
		exec('svn log -l 1 '+REPO_DIR, callback);
	};

	/**
	 *	Get last revision number from SVN and pass output to the callback function.
	 *
	 *	@param {execCallback} callback
	 */
	this.svn.lastRevision = function(callback){
		exec('svn info -rHEAD '+REPO_DIR, callback);
	};

	/**
	 *	Commit all changes to the SVN repository.
	 *
	 *	@param {RepoChanges} changes
	 *	@param {String} message
	 *	@param {execCallback} callback
	 */
	this.svn.save = function(changes, message, callback){
		// Add and Delete files to/from SVN.
		self.svn.add(changes.A, function(){
			self.svn.delete(changes.D, function(){
				// Prepare SVN changelist.
				var changeset = Date.now() + '_' + crypto.randomBytes(4).readUInt32LE(0);
				self.svn.changeList(changeset, changes.A.concat(changes.M, changes.D), function(){
					// Just in case, run regular commit for possible new directories first.
					self.svn.commit(message, false, function(error, stdout, stderr){
						// Finally, commit changeset to the SVN repository.
						self.svn.commit(message, changeset, function(error, stdout, stderr){
							callback(error, stdout, stderr);
						});
					});
				});
			});
		});
	};


	/**
	 *	@member {RepoManager} svn manager
	 */
	this.git = {};

	/**
	 *	Parse list of changed files from GIT.
	 *	This function is synchronous!
	 *
	 *	@param {string} stdout - data returned from `git status --porcelain` or `git diff --name-status` commands
	 *
	 *	@returns {RepoChanges}
	 */
	this.git.parseChanges = function(stdout){
		var match = null;
		var regex = /([\?!DAM]+)\s+([^\r\n]+)/g;
		var result = {A: [], M: [], D: []};
		while ((match = regex.exec(stdout))) {
			if (match[1] === '!') {
				match[1] = 'D';
			}
			else if (match[1] === '??') {
				match[1] = 'A';
			}

			result[match[1]].push(path.join(REPO_DIR, match[2]));
		}
		return result;
	};

	/**
	 *	Parse commit message from GIT log stdout.
	 *	This function is synchronous!
	 *
	 *	@param {string} stdout - data returned from `git log --name-status --pretty="format:commit: %H%n%n%s%n%b%n%n"` command
	 *
	 *	@returns {string}
	 */
	this.git.parseMessage = function(stdout){
		return stdout.replace(/^[\r\n\s]*commit:?\s*[^\r\n]+/, '').replace(/[\r\n]+([AMD\?\!]+\s+[^\r\n]+[\r\n])*$/, '').trim();
	};

	/**
	 *	Parse revision number from GIT info format.
	 *	This function is synchronous!
	 *
	 *	@param {string} stdout - data returned from `git log --name-status --pretty="format:commit: %H%n%n%s%n%b%n%n"` command
	 *
	 *	@returns {string}
	 */
	this.svn.parseRevision = function(stdout){
		var revision = stdout.match(/[\r\n\s]+commit:?\s*([0-9a-z]{40})/i);
		if (revision) {
			revision = revision[1];
		}
		else {
			revision = false;
		}
		return revision;
	};

	/**
	 *	Get GIT status and pass it to the callback function.
	 *
	 *	@param {execCallback} callback
	 */
	this.git.status = function(callback){
		self.exec(['git', 'status', '--porcelain'], REPO_DIR, callback);
	};

	/**
	 *	Get GIT diff between two revisions and pass output (list of changed files) to the callback function.
	 *
	 *	@param {string} start - start revision SHA
	 *	@param {string} end - end revision SHA
	 *	@param {execCallback} callback
	 */
	this.git.diff = function(start, end, callback){
		self.exec(['git', 'diff', start, end, '--name-status'], REPO_DIR, callback);
	};

	/**
	 *	Add files to GIT.
	 *
	 *	@param {Array} fileList - list of files to be added to the GIT
	 *	@param {execCallback} callback
	 */
	this.git.add = function(fileList, callback){
		var _gitadd;
		var files = fileList.slice();

		_gitadd = function(){
			if (files.length < 1) {
				callback();
				return;
			}

			// Remove REPO_DIR from the beginning of path, which was added when list of changes was generated.
			var file = path.join.apply(files.shift().split(path.sep).slice(1));
			self.exec(['git', 'add', file], REPO_DIR, _gitadd);
		};

		_gitadd();
	};

	/**
	 *	Delete files from GIT.
	 *
	 *	@param {Array} fileList - list of files to be deleted from the GIT
	 *	@param {execCallback} callback
	 */
	this.git.delete = function(fileList, callback){
		var _gitdelete;
		var files = fileList.slice();

		_gitdelete = function() {
			if (files.length < 1) {
				callback();
				return;
			}

			// Remove REPO_DIR from the beginning of path, which was added when list of changes was generated.
			var file = path.join.apply(files.shift().split(path.sep).slice(1));
			self.exec(['git', 'rm', file], REPO_DIR, _gitdelete);
		};

		_gitdelete();
	};

	/**
	 *	Get the name of the main remote repository.
	 *	Name of the remote will be in stdout.
	 *
	 *	@param {execCallback} callback
	 */
	this.git.remote = function(callback){
		self.exec(['git', 'remote'], REPO_DIR, function(error, stdout, stderr){
			callback(error, stdout.trim(), stderr);
		});
	};

	/**
	 *	Update from GIT and pass output to the callback function.
	 *
	 *	@param {execCallback} callback
	 */
	this.git.update = function(callback){
		self.git.remote(function(error, stdout, stderr) {
			if (!error && stdout) {
				self.exec(['git', 'pull'], REPO_DIR, callback);
			}
			else {
				callback(false, '', '');
			}
		});
	};

	/**
	 *	Commit to GIT.
	 *
	 *	@param {string} message - commit message
	 *	@param {null} dummy - not used
	 *	@param {execCallback} callback
	 */
	this.git.commit = function(message, dummy, callback){
		self.exec(['git', 'commit', '-a', '-m', message], REPO_DIR, function(error, stdout, stderr){
			if (error) {
				callback(error, stdout, stderr);
				return;
			}

			self.git.remote(function(error, stdout, stderr) {
				if (!error && stdout) {
					self.exec(['git', 'push'], REPO_DIR, callback);
				}
				else {
					callback(false, '', '');
				}
			});
		});
	};

	/**
	 *	Get last commit info from GIT and pass output to the callback function.
	 *
	 *	@param {execCallback} callback
	 */
	this.git.lastCommit = function(callback){
		self.exec(['git', 'log', '--name-status', '--pretty=commit: %H%n%n%s%n%b%n%n', '-1'], REPO_DIR, callback);
	};

	/**
	 *	Get last revision number from GIT and pass output to the callback function.
	 *
	 *	@param {execCallback} callback
	 */
	this.git.lastRevision = function(callback){
		self.exec(['git', 'log', '--name-status', '--pretty="commit: %H%n%n%s%n%b%n%n"', '-1'], REPO_DIR, callback);
	};

	/**
	 *	Commit all changes to the GIT repository.
	 *
	 *	@param {RepoChanges} changes
	 *	@param {String} message
	 *	@param {execCallback} callback
	 */
	this.git.save = function(changes, message, callback){
		console.log('saving..');
		// Add and Delete files to/from GIT.
		self.git.add(changes.A, function(){
			console.log('added');
			self.git.delete(changes.D, function(){
				console.log('deleted');
				// Finally, commit changeset to the GIT repository.
				self.git.commit(message, false, function(error, stdout, stderr){
					callback(error, stdout, stderr);
				});
			});
		});
	};


	/**
	 *	"Ping" URL using HTTP GET.
	 *
	 *	@param {Object} urlObject - parsed URL (http://nodejs.org/api/url.html#apicontent)
	 *	@param {execCallback} callback
	 */
	this.callServer = function(urlObject, callback) {
		if (!(urlObject instanceof Object)) {
			urlObject = url.parse(urlObject);
		}

		console.log('Calling '+urlObject.href);
		(urlObject.protocol == 'https:' ? https : http).get(urlObject.href, function(res) {
			var stdout = '';
			res.on('data', function(chunk) {
				stdout += chunk;
			});
			res.on('end', function(chunk) {
				stdout += (chunk ? chunk : '');
				callback(false, stdout, '');
			});
		}).on('error', function(e) {
			callback(true, '', e.message);
		});
	};

	/**
	 *	Execute a script.
	 *	urlObject's protocol can be one of: 'node:', 'python:', 'php:', 'sh:' or 'ruby:'.
	 *	urlObject's hostname and pathname will be used as a first argument passed to the executable.
	 *	urlObject's query variables will be used as additional arguments passed to the executable, e.g.,
	 *
	 *		node:///home/user/script.js?--something=value&--else=value2
	 *
	 *	will form a command:
	 *
	 *		node /home/user/script.js --somethin value --else value2
	 *
	 *	@param {Object} urlObject - parsed URL (http://nodejs.org/api/url.html#apicontent)
	 *	@param {execCallback} callback
	 */
	this.callScript = function(urlObject, callback) {
		if (!(urlObject instanceof Object)) {
			urlObject = url.parse(urlObject);
		}

		var command = [urlObject.protocol.replace(/\:$/, ''), path.join(urlObject.hostname || '', urlObject.pathname || '')];
		if (urlObject.query) {
			for (var a in urlObject.query) {
				if (urlObject.query.hasOwnProperty(a)) {
					command.push(a);
					if (urlObject.query[a]) {
						command.push(urlObject.query[a]);
					}
				}
			}
		}

		self.exec(command, callback);
	};

	/**
	 *	Make sure that path exists in target SFTP directory.
	 *
	 *	@param {Object} sftp connection
	 *	@param {string} targetDirectory
	 *	@param {string} localDirectory
	 *	@param {execCallback} callback
	 */
	this.sftpSyncronizeDirectoryPath = function(sftp, targetDirectory, localDirectory, callback){
		var _sftpSyncronizeDirectoryPath;
		var dirs = localDirectory.split(path.sep);
		var cwd = targetDirectory;

		_sftpSyncronizeDirectoryPath = function() {
			if (dirs.length < 1) {
				callback();
				return;
			}

			cwd += dirs.shift() + '/';
			sftp.stat(cwd, function(error){
				if (!error) {
					_sftpSyncronizeDirectoryPath();
					return;
				}

				sftp.mkdir(cwd, function(error){
					if (!error) {
						_sftpSyncronizeDirectoryPath();
						return;
					}

					callback(true, '', error);
				});
			});
		};

		_sftpSyncronizeDirectoryPath();
	};

	/**
	 *	Upload files to target SFTP directory.
	 *
	 *	@param {Object} sftp connection
	 *	@param {string} targetDirectory
	 *	@param {string} ignorePrefix - remove prefix from path before uploading, e.g., trunk/publish so file from that directory will land in targetDirectory, instead of targetDirectory/trunk/publish.
	 *	@param {Array} fileList - list of files to be uploaded
	 *	@param {execCallback} callback
	 */
	this.sftpUploadList = function(sftp, targetDirectory, ignorePrefix, fileList, callback){
		var _sftpUploadList;
		var files = fileList.slice();

		var resultError = false;
		var resultStdout = '';
		var resultStderr = '';

		var _logged = '';
		var _logClear = function(){
			process.stdout.write('SFTP: ');
			_logged = '';
		};
		var _logStep = function(transferred, chunk, total){
			var done = Math.floor(transferred / total * 20);
			if (_logged.length < done) {
				var out = '';
				for (var i = 0; i < done - _logged.length; i++) {
					_logged += '.';
					out += '.';
				}
				process.stdout.write(out);
			}
		};

		var ignorePath = [];
		if (ignorePrefix) {
			ignorePath = ignorePrefix.split(path.sep);
		}

		_sftpUploadList = function() {
			if (files.length < 1) {
				callback(resultError, resultStdout, resultStderr);
				return;
			}

			var filePath = files.shift();
			fs.stat(filePath, function(error, stats){
				if (error) {
					resultError = true;
					stderr += error;
					callback(resultError, resultStdout, resultStderr);
					return;
				}

				var targetFile = filePath.split(path.sep);

				// First part is local REPO_DIR, so shift it out.
				if (targetFile[0] === REPO_DIR) {
					targetFile.shift();
				}

				if (ignorePath.length > 0) {
					ignorePath.forEach(function(value){
						if (targetFile[0] === value) {
							targetFile.shift();
						}
					});
				}

				targetFile = targetFile.join('/');

				self.sftpSyncronizeDirectoryPath(sftp, targetDirectory, (stats.isDirectory() ? targetFile : path.dirname(targetFile)), function(error, stdout, stderr) {
					resultStdout += stdout;

					if (error) {
						resultError = true;
						resultStderr += stderr;
						callback(resultError, resultStdout, resultStderr);
						return;
					}

					targetFile = targetDirectory + targetFile;
					console.log('SFTP: Uploading: ' + targetFile);

					_logClear();
					sftp.fastPut(filePath, targetFile, {step: _logStep}, function(error){
						if (error) {
							resultError = true;
							resultStderr += error;
							callback(resultError, resultStdout, resultStderr);
							return;
						}

						process.stdout.write("\n");
						console.log('SFTP: Uploaded: ' + targetFile);
						_sftpUploadList();
					});
				});
			});
		};

		_sftpUploadList();
	};

	/**
	 *	Upload files to server.
	 *	urlObject should point to the root directory, where the files will be uploaded.
	 *
	 *	@param {Object} urlObject - parsed URL (http://nodejs.org/api/url.html#apicontent)
	 *	@param {execCallback} callback
	 */
	this.callSFTP = function(urlObject, callback) {
		if (!(urlObject instanceof Object)) {
			urlObject = url.parse(urlObject);
		}

		if (urlObject.pathname[urlObject.pathname.length-1] != '/') {
			console.log('ERROR: SFTP target directory path should end with a slash ("/").');
		}

		var auth = (urlObject.auth || 'username:password').split(':');
		var params = (urlObject.query ? querystring.parse(urlObject.query) : false);

		var settings = {
			host: urlObject.hostname,
			port: urlObject.port || 22,
			path: urlObject.pathname || '/',
			username: auth[0] || 'username',
			password: auth[1] || 'password',
			start: '',
			end: 'HEAD',
			ignorePrefix: (params && params.hasOwnProperty('ignorePrefix') ? params.ignorePrefix : '')
		};

		var parseUserData = function(data) {
			var info = data.replace(/(^|[\r\n]+)#\s[^\r\n]*/g, '').replace(/^[\r\n\s]+|[\r\n\s]+$/g, '').split(/[\r\n]+/);
			var u = url.parse(info[0]);
			settings.host = u.hostname;
			settings.port = u.port;
			settings.path = u.pathname;
			settings.start = info[1];
			settings.end = info[2];
			settings.username = info[3];
			settings.password = info[4];

			params = (u.query ? querystring.parse(u.query) : false);
			settings.ignorePrefix = (params && params.hasOwnProperty('ignorePrefix') ? params.ignorePrefix : '');
		};

		var addChangesToString = function(data, changes) {
			var result = data.replace(/(^|[\r\n]+)\# Changed:([\w\W])*$/g, '') + "\n\n# Changed:\n";

			changes.A.forEach(function(value, index){
				result += 'A '+value+"\n";
			});
			changes.M.forEach(function(value, index){
				result += 'M '+value+"\n";
			});
			changes.D.forEach(function(value, index){
				result += 'D '+value+"\n";
			});

			return result;
		};

		self.svn.lastRevision(function(error, stdout, stderr){
			var HEAD = parseInt(self.svn.parseRevision(stdout), 10) || 1;

			settings.end = HEAD;
			settings.start = HEAD - 1;

			var intro = "# Enter server URL with root path, start revision number, end revision number, user name and password - each in a new line, e.g.,\n# \tsftp://example.com:22/root/some/directory/myproject/\n# \t123\n# \t125\n# \tmyusername\n# \tmypassword\n# \n# End revision defaults to current HEAD.\n# Start revision defaults to HEAD-1.\n\nsftp://" + settings.host + ':' + settings.port + settings.path + (settings.ignorePrefix ? '?ignorePrefix='+settings.ignorePrefix : '') + "\n" + settings.start + "\n" + settings.end + "\n" + settings.username + "\n" + settings.password;
			self.svn.diff(settings.start, settings.end, function(error, stdout, stderr){
				var changes = self.svn.parseChanges(stdout);
				var data = addChangesToString(intro, changes);

				var filename = self.getDataFromUser(data, 12, 0, function(data){
					parseUserData(data.toString());
					//console.log(settings);

					self.svn.diff(settings.start, settings.end, function(error, stdout, stderr){
						var changes = self.svn.parseChanges(stdout);
						//console.log(changes);

						var c = new MODULES.ssh2();
						c.on('connect', function(){
							console.log('SSH: Connected to '+settings.host+':'+settings.port);
						});
						c.on('ready', function(){
							console.log('SSH: ready.');
							c.sftp(function(error, sftp){
								if (error) {
									console.log('SFTP: ERROR: '+error);
									c.end();
									return;
								}

								sftp.on('end', function(){
									console.log('SFTP: end.');
									c.end();
								});

								self.sftpUploadList(sftp, settings.path, settings.ignorePrefix, changes.A.concat(changes.M), function(error, stdout, stderr){
									if (error) {
										console.log('SFTP: ERROR: '+stderr);
									}
									else {
										console.log('SFTP: upload finished.');
									}
									c.end();
								});
							});
						});
						c.on('error', function(error){
							console.log('SSH: ERROR: '+error);
						});
						c.on('end', function(){
							console.log('SSH: end.');
						});
						c.on('close', function(){
							console.log('SSH: closed.');
							callback(false, 'Finished', '');
						});

						console.log('SSH: connecting to server...');
						c.connect({
							host: settings.host,
							port: settings.port,
							username: settings.username,
							password: settings.password
						});
					});
				});

				fs.watch(filename, function(event){
					var start = settings.start;
					var end = settings.end;
					if (event === 'change') {
						var data = fs.readFileSync(filename);
						parseUserData(data.toString());
						if (settings.start != start || settings.end != end) {
							self.svn.diff(settings.start, settings.end, function(error, stdout, stderr){
								var changes = self.svn.parseChanges(stdout);

								data = addChangesToString(fs.readFileSync(filename).toString(), changes);
								fs.writeFileSync(filename, data);
							});
						}
					}
				});
			});

		});
	};

	/**
	 *	Initialize protocols map.
	 *	This function is synchronous!
	 */
	this.initProtocols = function(){
		PROTOCOLS['python:'] = self.callScript;
		PROTOCOLS['node:'] = self.callScript;
		PROTOCOLS['ruby:'] = self.callScript;
		PROTOCOLS['php:'] = self.callScript;
		PROTOCOLS['cmd:'] = self.callScript;
		PROTOCOLS['sh:'] = self.callScript;

		PROTOCOLS['https:'] = self.callServer;
		PROTOCOLS['http:'] = self.callServer;

		PROTOCOLS['svn:'] = self.commit;
		PROTOCOLS['git:'] = self.commit;

		if (MODULES.hasOwnProperty('ssh2') && MODULES.ssh2) {
			PROTOCOLS['sftp:'] = self.callSFTP;
		}
	};

	/**
	 *	Automagically select repository manager.
	 *
	 *	@param {execCallback} callback
	 */
	this.initRepositoryManager = function(managers, callback){
		var _checkRepo;
		var _managers = managers.slice();

		self.repo = null;

		_checkRepo = function(){
			if (managers.length < 1) {
				callback('No supported repository could be found.');
				return;
			}

			var type = managers.shift();
			fs.exists(path.join(REPO_DIR, '.'+type), function(exists){
				if (exists) {
					self.repo = self[type];
					callback(false, 'Using '+type.toUpperCase()+' repository.', '');
				}
				else {
					_checkRepo();
				}
			});
		};

		_checkRepo();
	};

	// Parse REPO_NAME and REPO_TYPE (if available).
	(function(){
		var type = REPO_DIR.match(/^(svn|git):\/\//i);
		if (type && type.length > 1 && type[1]) {
			REPO_DIR = REPO_DIR.replace(/^(svn|git):\/\//i, '');
			REPO_TYPE = type[1];
		}
	})();

	// Some initialization checks.
	fs.exists(REPO_DIR, function(exists){
		if (!exists) {
			process.nextTick(function(){
				self.emit('error', 'could not find REPO_DIR called "'+REPO_DIR+'"');
			});
			return;
		}

		var repoManagers = (REPO_TYPE ? [REPO_TYPE] : ['git', 'svn']);
		self.initRepositoryManager(repoManagers, function(error, stdout, stderr){
			if (error) {
				self.emit('error', error + (stderr ? stderr : ''));
				return;
			}

			console.log(stdout);

			// Try to install/require ssh2 module (https://github.com/mscdex/ssh2), for our sftp command to use.
			self.require('ssh2', function(error, stdout, stderr){
				if (error) {
					console.log('ERROR: could not install ssh2 module - ssh2ftp command will not be available.');
					console.log(stdout+stderr);
				}

				self.initProtocols();
				process.nextTick(function(){
					self.emit('ready');
				});
			});
		});
	});
};
// Inherit Builder from EventEmitter.
util.inherits(Builder, emitter);

// Autorun when called directly as a utility, instead of required as a library.
if (process.argv[1] != path.basename(__filename)) (function(){
	var repo = process.argv[2] || false;
	if (!repo) {
		console.log('USAGE: node '+path.basename(__filename)+' REPOSITORY_NAME [URL, ...]');
		return;
	}

	var builder = new Builder(repo);
	builder.on('ready', function(){
		builder.commit(function(error, stdout, stderr){
			console.log(stdout+(error ? stderr : ''));
			var commands = process.argv.length - 3;
			var outputter = function(error, stdout, stderr){
				console.log(stdout+(error ? stderr : ''));
				commands--;
				if (commands < 1) {
					process.exit();
				}
			};
			if (commands > 0 && process.argv[3]) {
				for (var i = 3; i < process.argv.length; i++) {
					builder.callURL(process.argv[i], outputter);
				}
			}
			else {
				process.exit();
			}
		});
	});
	builder.on('error', function(error){
		console.log('ERROR: '+error);
		process.exit(-1);
	});
}());