From 8d5bba95584c040d0fd66adf884dde07e4594fe0 Mon Sep 17 00:00:00 2001 From: Juan Cruz Viotti Date: Thu, 11 Dec 2014 15:51:22 -0400 Subject: [PATCH] Implement VCS module --- lib/resin/errors/errors.coffee | 16 +++ lib/resin/index.coffee | 1 + lib/resin/settings.coffee | 1 + lib/resin/vcs/git/git.coffee | 106 ++++++++++++++ lib/resin/vcs/git/git.spec.coffee | 231 ++++++++++++++++++++++++++++++ lib/resin/vcs/vcs.coffee | 5 + 6 files changed, 360 insertions(+) create mode 100644 lib/resin/vcs/git/git.coffee create mode 100644 lib/resin/vcs/git/git.spec.coffee create mode 100644 lib/resin/vcs/vcs.coffee diff --git a/lib/resin/errors/errors.coffee b/lib/resin/errors/errors.coffee index 603c9da5..23705628 100644 --- a/lib/resin/errors/errors.coffee +++ b/lib/resin/errors/errors.coffee @@ -78,6 +78,22 @@ exports.InvalidPath = class InvalidPath extends TypedError # Error code code: 1 +exports.DirectoryDoesntExist = class DirectoryDoesntExist extends TypedError + + # Construct a Directory Doesn't Exist error + # + # @param {String} directory the name of the directory that doesn't exist + # + # @example Directory doesn't exist error + # throw new resin.errors.DirectoryDoesntExist('/tmp') + # Error: Directory doesn't exist: /tmp + # + constructor: (directory) -> + @message = "Directory doesn't exist: #{directory}" + + # Error code + code: 1 + exports.NotAny = class NotAny extends TypedError # Construct an Not Any error diff --git a/lib/resin/index.coffee b/lib/resin/index.coffee index 0bf0ba2f..71e7cec1 100644 --- a/lib/resin/index.coffee +++ b/lib/resin/index.coffee @@ -8,4 +8,5 @@ module.exports = auth: require('./auth/auth') device: require('./device/device') os: require('./os/os') + vcs: require('./vcs/vcs') settings: require('./settings') diff --git a/lib/resin/settings.coffee b/lib/resin/settings.coffee index 2db20f5a..62873088 100644 --- a/lib/resin/settings.coffee +++ b/lib/resin/settings.coffee @@ -11,6 +11,7 @@ settings = dataPrefix: path.join(userHome, '.resin') sshKeyWidth: 43 + gitRemote: 'resin' directories: plugins: 'plugins' diff --git a/lib/resin/vcs/git/git.coffee b/lib/resin/vcs/git/git.coffee new file mode 100644 index 00000000..7e2df76a --- /dev/null +++ b/lib/resin/vcs/git/git.coffee @@ -0,0 +1,106 @@ +fs = require('fs') +fsPlus = require('fs-plus') +_ = require('lodash') +async = require('async') +path = require('path') +gitCli = require('git-cli') +errors = require('../../errors/errors') +settings = require('../../settings') + +nodeify = (func) -> + return -> + return func.call(null, null, arguments...) + +exports.getGitDirectory = (directory) -> + return if not directory? + if not _.isString(directory) + throw new Error('Invalid git directory') + return path.join(directory, '.git') + +exports.getCurrentGitDirectory = -> + currentDirectory = process.cwd() + return exports.getGitDirectory(currentDirectory) + +exports.isGitRepository = (directory, callback) -> + gitDirectory = exports.getGitDirectory(directory) + + async.waterfall([ + + (callback) -> + fs.exists(directory, nodeify(callback)) + + (exists, callback) -> + return callback() if exists + error = new errors.DirectoryDoesntExist(directory) + return callback(error) + + (callback) -> + fsPlus.isDirectory(gitDirectory, nodeify(callback)) + + ], callback) + +exports.getRepository = (directory, callback) -> + exports.isGitRepository directory, (error, isGitRepository) -> + return callback(error) if error? + + if not isGitRepository + error = new Error("Not a git directory: #{directory}") + return callback(error) + + gitDirectory = exports.getGitDirectory(directory) + repository = new gitCli.Repository(gitDirectory) + return callback(null, repository) + +exports.isValidGitApplication = (application) -> + gitRepository = application.git_repository + return false if not gitRepository? + return false if not _.isString(gitRepository) + return true + +exports.hasRemote = (repository, name, callback) -> + repository.listRemotes null, (error, remotes) -> + return callback(error) if error? + hasRemote = _.indexOf(remotes, name) isnt -1 + return callback(null, hasRemote) + +# TODO: This should be better tested +exports.addRemote = (repository, name, url, callback) -> + if not _.isString(name) + error = new Error("Invalid remote name: #{name}") + return callback(error) + + repository.addRemote(name, url, callback) + +# TODO: This should be better tested +exports.initApplication = (application, directory, callback) -> + + async.waterfall([ + + (callback) -> + isValid = exports.isValidGitApplication(application) + return callback() if isValid + error = new Error("Invalid application: #{application}") + return callback(error) + + (callback) -> + exports.getRepository(directory, callback) + + (repository, callback) -> + gitUrl = application.git_repository + gitRemoteName = settings.get('gitRemote') + exports.addRemote(repository, gitRemoteName, gitUrl, callback) + + ], callback) + +# TODO: Find a sane way to test this +exports.wasInitialized = (directory, callback) -> + async.waterfall([ + + (callback) -> + exports.getRepository(directory, callback) + + (repository, callback) -> + gitRemoteName = settings.get('gitRemote') + exports.hasRemote(repository, gitRemoteName, callback) + + ], callback) diff --git a/lib/resin/vcs/git/git.spec.coffee b/lib/resin/vcs/git/git.spec.coffee new file mode 100644 index 00000000..31b6a8b8 --- /dev/null +++ b/lib/resin/vcs/git/git.spec.coffee @@ -0,0 +1,231 @@ +_ = require('lodash') +path = require('path') +sinon = require('sinon') +gitCli = require('git-cli') +chai = require('chai') +expect = chai.expect +git = require('./git') +mock = require('../../../../tests/utils/mock') +settings = require('../../settings') + +describe 'VCS Git:', -> + + describe '#getGitDirectory()', -> + + it 'should append .git', -> + result = git.getGitDirectory('foobar') + expect(result).to.equal("foobar#{path.sep}.git") + + it 'should return undefined if no directory', -> + for input in [ undefined, null ] + result = git.getGitDirectory(input) + expect(result).to.be.undefined + + it 'should throw an error if directory is not a string', -> + for input in [ + 123 + { hello: 'world' } + [ 1, 2, 3 ] + true + false + ] + func = _.partial(git.getGitDirectory, input) + expect(func).to.throw(Error) + + describe '#getCurrentGitDirectory()', -> + + it 'should append .git to current working directory', -> + result = git.getCurrentGitDirectory() + expectedResult = path.join(process.cwd(), '.git') + expect(result).to.equal(expectedResult) + + describe '#getRepository()', -> + + filesystem = + gitRepo: + name: '/repo' + contents: + '.git': {} + + beforeEach -> + mock.fs.init(filesystem) + + afterEach -> + mock.fs.restore() + + it 'should throw an error if directory does not exist', (done) -> + git.getRepository '/foobar', (error, repository) -> + expect(error).to.be.an.instanceof(Error) + expect(repository).to.not.exist + done() + + it 'should return a repository', (done) -> + repo = filesystem.gitRepo + git.getRepository repo.name, (error, repository) -> + expect(error).to.not.exist + expect(repository).to.exist + + expectedPath = path.join(repo.name, '.git') + expect(repository.path).to.equal(expectedPath) + done() + + describe '#isValidGitApplication()', -> + + it 'should return false if no git_repository', -> + result = git.isValidGitApplication({}) + expect(result).to.be.false + + it 'should return false if git_repository is not a string', -> + result = git.isValidGitApplication(git_repository: [ 1, 2, 3 ]) + expect(result).to.be.false + + it 'should return true if git_repository is valid', -> + repositoryUrl = 'git@git.resin.io:johndoe/app.git' + result = git.isValidGitApplication(git_repository: repositoryUrl) + expect(result).to.be.true + + describe '#hasRemote()', -> + + mockListRemotes = (result) -> + return (options, callback) -> + return callback(null, result) + + beforeEach -> + @repository = + listRemotes: mockListRemotes([ 'resin', 'origin' ]) + + it 'should return true if it has the remote', (done) -> + git.hasRemote @repository, 'resin', (error, hasRemote) -> + expect(error).to.not.exist + expect(hasRemote).to.be.true + done() + + it 'should return false if it does not have the remote', (done) -> + git.hasRemote @repository, 'foobar', (error, hasRemote) -> + expect(error).to.not.exist + expect(hasRemote).to.be.false + done() + + describe '#addRemote()', -> + + beforeEach -> + @repository = + addRemote: (name, url, callback) -> + return callback() + + @name = 'resin' + @url = 'git@git.resin.io:johndoe/app.git' + + # TODO: It'd be nice if we could actually test that + # the remote was added to .git/config, but sadly + # mockFs and child_process.exec don't seem to play well together. + + it 'should call repository.addRemote with the correct parameters', (done) -> + addRemoteSpy = sinon.spy(@repository, 'addRemote') + + callback = (error) => + expect(error).to.not.exist + expect(addRemoteSpy).to.have.been.calledWithExactly(@name, @url, callback) + addRemoteSpy.restore() + done() + + git.addRemote(@repository, @name, @url, callback) + + it 'should throw an error if name is not a string', (done) -> + git.addRemote @repository, undefined, @url, (error) -> + expect(error).to.be.an.instanceof(Error) + done() + + describe '#isGitRepository()', -> + + filesystem = + gitRepo: + name: '/repo' + contents: + '.git': {} + notGitRepo: + name: '/not-repo' + contents: {} + invalidGitRepo: + name: '/invalid-repo' + contents: + '.git': 'Plain text file' + + beforeEach -> + mock.fs.init(filesystem) + + afterEach -> + mock.fs.restore() + + it 'should return true if it has a .git directory', (done) -> + git.isGitRepository filesystem.gitRepo.name, (error, isGitRepo) -> + expect(error).to.not.exist + expect(isGitRepo).to.be.true + done() + + it 'should return false if it does not have a .git directory', (done) -> + git.isGitRepository filesystem.notGitRepo.name, (error, isGitRepo) -> + expect(error).to.not.exist + expect(isGitRepo).to.be.false + done() + + it 'should throw an error if directory does not exist', (done) -> + git.isGitRepository '/nonexistentdir', (error, isGitRepo) -> + expect(error).to.be.an.instanceof(Error) + expect(isGitRepo).to.be.undefined + done() + + it 'should return false it .git is a file', (done) -> + git.isGitRepository filesystem.invalidGitRepo.name, (error, isGitRepo) -> + expect(error).to.not.exist + expect(isGitRepo).to.be.false + done() + + describe '#initApplication()', -> + + filesystem = + gitRepo: + name: '/repo' + contents: + '.git': {} + + notGitRepo: + name: '/not-repo' + contents: {} + + beforeEach -> + mock.fs.init(filesystem) + @application = + git_repository: 'git@git.resin.io:johndoe/app.git' + + afterEach -> + mock.fs.restore() + + it 'should return an error if directory is not a git repo', (done) -> + git.initApplication @application, filesystem.notGitRepo.name, (error) -> + expect(error).to.be.an.instanceof(Error) + done() + + it 'should return an error if application does not contain a git repo url', (done) -> + git.initApplication {}, filesystem.gitRepo.name, (error) -> + expect(error).to.be.an.instanceof(Error) + done() + + it 'should add the remote', (done) -> + mock.fs.restore() + addRemoteStub = sinon.stub(git, 'addRemote') + addRemoteStub.yields(null) + mock.fs.init(filesystem) + + git.initApplication @application, filesystem.gitRepo.name, (error) => + expect(error).to.not.exist + expect(addRemoteStub).to.have.been.calledOnce + + # TODO: There should be a better way to test this + args = addRemoteStub.firstCall.args + expect(args[1]).to.equal(settings.get('gitRemote')) + expect(args[2]).to.equal(@application.git_repository) + + addRemoteStub.restore() + done() + diff --git a/lib/resin/vcs/vcs.coffee b/lib/resin/vcs/vcs.coffee new file mode 100644 index 00000000..6c0498a2 --- /dev/null +++ b/lib/resin/vcs/vcs.coffee @@ -0,0 +1,5 @@ +git = require('./git/git') + +# We will delegate to git for now +exports.initApplication = git.initApplication +exports.wasInitialized = git.wasInitialized