diff --git a/lib/nanoc/deploying/deployers.rb b/lib/nanoc/deploying/deployers.rb index d71ce0a736..703862ef0d 100644 --- a/lib/nanoc/deploying/deployers.rb +++ b/lib/nanoc/deploying/deployers.rb @@ -3,4 +3,5 @@ module Nanoc::Deploying::Deployers end require_relative 'deployers/fog' +require_relative 'deployers/git' require_relative 'deployers/rsync' diff --git a/lib/nanoc/deploying/deployers/git.rb b/lib/nanoc/deploying/deployers/git.rb new file mode 100644 index 0000000000..d0fc135904 --- /dev/null +++ b/lib/nanoc/deploying/deployers/git.rb @@ -0,0 +1,118 @@ +module Nanoc::Deploying::Deployers + # A deployer that deploys a site using [Git](http://git-scm.com). + # + # @example A deployment configuration for GitHub Pages: + # + # deploy: + # default: + # kind: git + # remote: git@github.com:myself/myproject.git + # branch: gh-pages + # forced: true + # + class Git < ::Nanoc::Deploying::Deployer + identifier :git + + module Errors + class Generic < ::Nanoc::Error + end + + class OutputDirDoesNotExist < Generic + def initialize(path) + super("The directory to deploy, #{path}, does not exist.") + end + end + + class OutputDirIsNotAGitRepo < Generic + def initialize(path) + super("The directory to deploy, #{path}, is not a Git repository.") + end + end + + class RemoteDoesNotExist < Generic + def initialize(remote) + super("The remote to deploy to, #{remote}, does not exist.") + end + end + + class BranchDoesNotExist < Generic + def initialize(branch) + super("The branch to deploy, #{branch}, does not exist.") + end + end + end + + def run + unless File.exist?(source_path) + raise Errors::OutputDirDoesNotExist.new(source_path) + end + + remote = config.fetch(:remote, 'origin') + branch = config.fetch(:branch, 'master') + forced = config.fetch(:forced, false) + + puts "Deploying via git to remote='#{remote}' and branch='#{branch}'" + + Dir.chdir(source_path) do + unless File.exist?('.git') + raise Errors::OutputDirIsNotAGitRepo.new(source_path) + end + + # Verify existence of remote, if remote is not a URL + if remote_is_name?(remote) + begin + run_cmd(%W(git config --get remote.#{remote}.url)) + rescue Nanoc::Extra::Piper::Error + raise Errors::RemoteDoesNotExist.new(remote) + end + end + + # If the branch exists then switch to it, otherwise prompt the user to create one. + begin + run_cmd_unless_dry(%W(git checkout #{branch})) + rescue Nanoc::Extra::Piper::Error + raise Errors::BranchDoesNotExist.new(branch) + end + + return if clean_repo? + + msg = "Automated commit at #{Time.now.utc} by Nanoc #{Nanoc::VERSION}" + author = 'Nanoc <>' + run_cmd_unless_dry(%w(git add -A)) + run_cmd_unless_dry(%W(git commit -a --author #{author} -m #{msg})) + + if forced + run_cmd_unless_dry(%W(git push -f #{remote} #{branch})) + else + run_cmd_unless_dry(%W(git push #{remote} #{branch})) + end + end + end + + private + + def remote_is_name?(remote) + remote !~ /:\/\/|@.+:/ + end + + def run_cmd(cmd) + piper = Nanoc::Extra::Piper.new(stdout: $stdout, stderr: $stderr) + piper.run(cmd, nil) + end + + def run_cmd_unless_dry(cmd) + if dry_run + puts cmd.join(' ') + else + run_cmd(cmd) + end + end + + def clean_repo? + stdout = StringIO.new + piper = Nanoc::Extra::Piper.new(stdout: stdout, stderr: $stderr) + piper.run(%w(git status --porcelain), nil) + stdout.string.empty? + end + end +end diff --git a/spec/nanoc/cli/commands/deploy_spec.rb b/spec/nanoc/cli/commands/deploy_spec.rb index b73e979565..241312ea4f 100644 --- a/spec/nanoc/cli/commands/deploy_spec.rb +++ b/spec/nanoc/cli/commands/deploy_spec.rb @@ -75,7 +75,7 @@ let(:run) { Nanoc::CLI.run(command) } it 'lists all deployers' do - expect { run }.to output(/Available deployers:\n fog\n rsync/).to_stdout + expect { run }.to output(/Available deployers:\n fog\n git\n rsync/).to_stdout end include_examples 'no effective deploy' diff --git a/spec/nanoc/deploying/git_spec.rb b/spec/nanoc/deploying/git_spec.rb new file mode 100644 index 0000000000..9476599a48 --- /dev/null +++ b/spec/nanoc/deploying/git_spec.rb @@ -0,0 +1,296 @@ +describe Nanoc::Deploying::Deployers::Git, stdio: true do + let(:deployer) { described_class.new(output_dir, options, dry_run: dry_run) } + + subject { deployer.run } + + let(:output_dir) { 'output/' } + let(:options) { remote_options.merge(branch_options).merge(forced_options) } + let(:dry_run) { false } + + let(:remote_options) { {} } + let(:branch_options) { {} } + let(:forced_options) { {} } + + def run_and_get_stdout(*args) + stdout = '' + piper = Nanoc::Extra::Piper.new(stdout: stdout, stderr: '') + piper.run(args, '') + stdout + end + + def add_changes_to_remote + system('git', 'init', '--quiet', 'rere_tmp') + Dir.chdir('rere_tmp') do + system('git', 'config', 'user.name', 'Zebra Platypus') + system('git', 'config', 'user.email', 'zebra@platypus.example.com') + system('git', 'remote', 'add', 'origin', '../rere') + + File.write('evil.txt', 'muaha') + system('git', 'add', 'evil.txt') + system('git', 'commit', '--quiet', '-m', 'muaha') + system('git', 'checkout', '--quiet', '-b', 'giraffe') + system('git', 'push', '--quiet', 'origin', 'master') + system('git', 'push', '--quiet', 'origin', 'giraffe') + end + end + + def rev_list + run_and_get_stdout('git', 'rev-list', '--objects', '--all') + end + + shared_examples 'branch configured properly' do + context 'clean working copy' do + it 'does not commit or push' do + subject + end + end + + context 'non-clean working copy' do + before do + Dir.chdir(output_dir) { File.write('hello.txt', 'Hi there') } + end + + shared_examples 'successful push' do + context 'no dry run' do + it 'makes a change in the local repo' do + expect { subject } + .to change { Dir.chdir(output_dir) { rev_list } } + .from(not_match(/^[a-f0-9]{40} hello\.txt$/)) + .to(match(/^[a-f0-9]{40} hello\.txt$/)) + + expect(Dir.chdir(output_dir) { run_and_get_stdout('git', 'show', branch) }) + .to match(/^Author: Nanoc <>$/) + end + + it 'makes a change in the remote repo' do + expect { subject } + .to change { Dir.chdir('rere') { rev_list } } + .from(not_match(/^[a-f0-9]{40} hello\.txt$/)) + .to(match(/^[a-f0-9]{40} hello\.txt$/)) + end + end + + context 'dry run' do + let(:dry_run) { true } + + it 'makes a change in the local repo' do + expect { subject } + .not_to change { Dir.chdir(output_dir) { rev_list } } + end + + it 'makes a change in the remote repo' do + expect { subject } + .not_to change { Dir.chdir('rere') { rev_list } } + end + end + end + + context 'forced' do + let(:forced_options) { { forced: true } } + + context 'remote has no other changes' do + include_examples 'successful push' + end + + context 'remote has other changes' do + before { add_changes_to_remote } + include_examples 'successful push' + end + end + + context 'not forced (implicit)' do + let(:forced_options) { {} } + + context 'remote has no other changes' do + include_examples 'successful push' + end + + context 'remote has other changes' do + before { add_changes_to_remote } + + it 'raises' do + expect { subject }.to raise_error(Nanoc::Extra::Piper::Error) + end + end + end + + context 'not forced (explicit)' do + let(:forced_options) { { forced: false } } + + context 'remote has no other changes' do + include_examples 'successful push' + end + + context 'remote has other changes' do + before { add_changes_to_remote } + + it 'raises' do + expect { subject }.to raise_error(Nanoc::Extra::Piper::Error) + end + end + end + end + end + + shared_examples 'remote configured properly' do + before do + system('git', 'init', '--bare', '--quiet', 'rere') + end + + context 'default branch' do + context 'branch does not exist' do + it 'raises' do + expect { subject }.to raise_error( + Nanoc::Deploying::Deployers::Git::Errors::BranchDoesNotExist, + 'The branch to deploy, master, does not exist.', + ) + end + end + + context 'branch exists' do + before do + Dir.chdir(output_dir) do + system('git', 'commit', '--quiet', '-m', 'init', '--allow-empty') + end + end + + let(:branch) { 'master' } + + include_examples 'branch configured properly' + end + end + + context 'custom branch' do + let(:branch) { 'giraffe' } + let(:branch_options) { { branch: branch } } + + context 'branch does not exist' do + it 'raises' do + expect { subject }.to raise_error( + Nanoc::Deploying::Deployers::Git::Errors::BranchDoesNotExist, + 'The branch to deploy, giraffe, does not exist.', + ) + end + end + + context 'branch exists' do + before do + Dir.chdir(output_dir) do + system('git', 'commit', '--quiet', '-m', 'init', '--allow-empty') + system('git', 'branch', 'giraffe') + end + end + + include_examples 'branch configured properly' + end + end + end + + context 'output dir does not exist' do + it 'raises' do + expect { subject }.to raise_error( + Nanoc::Deploying::Deployers::Git::Errors::OutputDirDoesNotExist, + 'The directory to deploy, output/, does not exist.', + ) + end + end + + context 'output dir exists' do + before do + FileUtils.mkdir_p(output_dir) + end + + context 'output dir is not a Git repo' do + it 'raises' do + expect { subject }.to raise_error( + Nanoc::Deploying::Deployers::Git::Errors::OutputDirIsNotAGitRepo, + 'The directory to deploy, output/, is not a Git repository.', + ) + end + end + + context 'output dir is a Git repo' do + before do + Dir.chdir(output_dir) do + system('git', 'init', '--quiet') + system('git', 'config', 'user.name', 'Donkey Giraffe') + system('git', 'config', 'user.email', 'donkey@giraffe.example.com') + end + end + + context 'default remote' do + context 'remote does not exist' do + it 'raises' do + expect { subject }.to raise_error( + Nanoc::Deploying::Deployers::Git::Errors::RemoteDoesNotExist, + 'The remote to deploy to, origin, does not exist.', + ) + end + end + + context 'remote exists' do + before do + Dir.chdir(output_dir) do + system('git', 'remote', 'add', 'origin', '../rere') + end + end + + let(:remote) { 'origin' } + + include_examples 'remote configured properly' + end + end + + context 'custom remote (name)' do + let(:remote_options) { { remote: 'donkey' } } + + context 'remote does not exist' do + it 'raises' do + expect { subject }.to raise_error( + Nanoc::Deploying::Deployers::Git::Errors::RemoteDoesNotExist, + 'The remote to deploy to, donkey, does not exist.', + ) + end + end + + context 'remote exists' do + before do + Dir.chdir(output_dir) do + system('git', 'remote', 'add', 'donkey', '../rere') + end + end + + let(:remote) { 'donkey' } + + include_examples 'remote configured properly' + end + end + + context 'custom remote (file:// URL)' do + let(:remote_options) { { remote: remote } } + + let(:remote) { "file://#{Dir.getwd}/rere" } + + include_examples 'remote configured properly' + end + end + end + + describe '#remote_is_name?' do + def val(remote) + deployer.send(:remote_is_name?, remote) + end + + it 'recognises names' do + expect(val('denis')).to be + end + + it 'recognises URLs' do + expect(val('git@github.com:/foo')).not_to be + expect(val('http://example.com/donkey.git')).not_to be + expect(val('https://example.com/donkey.git')).not_to be + expect(val('ssh://example.com/donkey.git')).not_to be + expect(val('file:///example.com/donkey.git')).not_to be + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 85bd899384..a31c4c88f0 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -76,6 +76,8 @@ end end +RSpec::Matchers.define_negated_matcher :not_match, :match + RSpec::Matchers.define :raise_frozen_error do |_expected| match do |actual| begin diff --git a/test/deploying/test_git.rb b/test/deploying/test_git.rb new file mode 100644 index 0000000000..daca139f36 --- /dev/null +++ b/test/deploying/test_git.rb @@ -0,0 +1,261 @@ +class Nanoc::Deploying::Deployers::GitTest < Nanoc::TestCase + def test_run_with_defaults_options + # Create deployer + git = Nanoc::Deploying::Deployers::Git.new( + 'output/', + {} + ) + + # Mock run_cmd + def git.run_cmd(args, _opts = {}) + @shell_cmd_args = [] unless defined? @shell_cmd_args + @shell_cmd_args << args.join(' ') + end + + # Mock clean_repo? + def git.clean_repo? + false + end + + # Create output dir + repo + FileUtils.mkdir_p('output') + Dir.chdir('output') { system('git', 'init', '--quiet') } + + # Try running + git.run + + commands = <<-EOS +git config --get remote.origin.url +git checkout master +git add -A +git commit -a --author Nanoc <> -m Automated commit at .+ by Nanoc \\d+\\.\\d+\\.\\d+\\w* +git push origin master +EOS + + assert_match Regexp.new(/^#{commands.chomp}$/), git.instance_eval { @shell_cmd_args.join("\n") } + end + + def test_run_with_clean_repository + # Create deployer + git = Nanoc::Deploying::Deployers::Git.new( + 'output/', + {} + ) + + # Mock run_cmd + def git.run_cmd(args, _opts = {}) + @shell_cmd_args = [] unless defined? @shell_cmd_args + @shell_cmd_args << args.join(' ') + end + + # Mock clean_repo? + def git.clean_repo? + true + end + + # Create output dir + repo + FileUtils.mkdir_p('output') + Dir.chdir('output') { system('git', 'init', '--quiet') } + + # Try running + git.run + + commands = <<-EOS +git config --get remote.origin.url +git checkout master +EOS + + assert_match Regexp.new(/^#{commands.chomp}$/), git.instance_eval { @shell_cmd_args.join("\n") } + end + + def test_run_with_custom_options + # Create deployer + git = Nanoc::Deploying::Deployers::Git.new( + 'output/', + remote: 'github', branch: 'gh-pages', forced: true, + ) + + # Mock run_cmd + def git.run_cmd(args, _opts = {}) + @shell_cmd_args = [] unless defined? @shell_cmd_args + @shell_cmd_args << args.join(' ') + end + + # Mock clean_repo? + def git.clean_repo? + false + end + + # Create output dir + repo + FileUtils.mkdir_p('output') + Dir.chdir('output') { system('git', 'init', '--quiet') } + + # Try running + git.run + + commands = <<-EOS +git config --get remote.github.url +git checkout gh-pages +git add -A +git commit -a --author Nanoc <> -m Automated commit at .+ by Nanoc \\d+\\.\\d+\\.\\d+\\w* +git push -f github gh-pages +EOS + + assert_match Regexp.new(/^#{commands.chomp}$/), git.instance_eval { @shell_cmd_args.join("\n") } + end + + def test_run_without_git_init + # Create deployer + git = Nanoc::Deploying::Deployers::Git.new( + 'output/', + {} + ) + + # Mock run_cmd + def git.run_cmd(args, _opts = {}) + @shell_cmd_args = [] unless defined? @shell_cmd_args + @shell_cmd_args << args.join(' ') + end + + # Mock clean_repo? + def git.clean_repo? + false + end + + # Create site + FileUtils.mkdir_p('output/.git') + + # Try running + git.run + + commands = <<-EOS +git config --get remote.origin.url +git checkout master +git add -A +git commit -a --author Nanoc <> -m Automated commit at .+ by Nanoc \\d+\\.\\d+\\.\\d+\\w* +git push origin master +EOS + + assert_match Regexp.new(/^#{commands.chomp}$/), git.instance_eval { @shell_cmd_args.join("\n") } + end + + def test_run_with_ssh_url + # Create deployer + git = Nanoc::Deploying::Deployers::Git.new( + 'output/', + remote: 'git@github.com:myself/myproject.git', + ) + + # Mock run_cmd + def git.run_cmd(args, _opts = {}) + @shell_cmd_args = [] unless defined? @shell_cmd_args + @shell_cmd_args << args.join(' ') + end + + # Mock clean_repo? + def git.clean_repo? + false + end + + # Create output dir + repo + FileUtils.mkdir_p('output') + Dir.chdir('output') { system('git', 'init', '--quiet') } + + # Try running + git.run + + commands = <<-EOS +git checkout master +git add -A +git commit -a --author Nanoc <> -m Automated commit at .+ by Nanoc \\d+\\.\\d+\\.\\d+\\w* +git push git@github.com:myself/myproject.git master +EOS + + assert_match Regexp.new(/^#{commands.chomp}$/), git.instance_eval { @shell_cmd_args.join("\n") } + end + + def test_run_with_http_url + # Create deployer + git = Nanoc::Deploying::Deployers::Git.new( + 'output/', + remote: 'https://github.com/nanoc/nanoc.git', + ) + + # Mock run_cmd + def git.run_cmd(args, _opts = {}) + @shell_cmd_args = [] unless defined? @shell_cmd_args + @shell_cmd_args << args.join(' ') + end + + # Mock clean_repo? + def git.clean_repo? + false + end + + # Create output dir + repo + FileUtils.mkdir_p('output') + Dir.chdir('output') { system('git', 'init', '--quiet') } + + # Try running + git.run + + commands = <<-EOS +git checkout master +git add -A +git commit -a --author Nanoc <> -m Automated commit at .+ by Nanoc \\d+\\.\\d+\\.\\d+\\w* +git push https://github.com/nanoc/nanoc.git master +EOS + + assert_match Regexp.new(/^#{commands.chomp}$/), git.instance_eval { @shell_cmd_args.join("\n") } + end + + def test_clean_repo_on_a_clean_repo + # Create deployer + git = Nanoc::Deploying::Deployers::Git.new( + 'output/', + remote: 'https://github.com/nanoc/nanoc.git', + ) + + FileUtils.mkdir_p('output') + + piper = Nanoc::Extra::Piper.new(stdout: $stdout, stderr: $stderr) + + Dir.chdir('output') do + piper.run('git init', nil) + assert git.send(:clean_repo?) + end + end + + def test_clean_repo_on_a_dirty_repo + # Create deployer + git = Nanoc::Deploying::Deployers::Git.new( + 'output/', + remote: 'https://github.com/nanoc/nanoc.git', + ) + + FileUtils.mkdir_p('output') + + piper = Nanoc::Extra::Piper.new(stdout: $stdout, stderr: $stderr) + Dir.chdir('output') do + piper.run('git init', nil) + FileUtils.touch('foobar') + refute git.send(:clean_repo?) + end + end + + def test_clean_repo_not_git_repo + # Create deployer + git = Nanoc::Deploying::Deployers::Git.new( + 'output/', + remote: 'https://github.com/nanoc/nanoc.git', + ) + + FileUtils.mkdir_p('output') + + Dir.chdir('output') do + assert_raises Nanoc::Extra::Piper::Error do + git.send(:clean_repo?) + end + end + end +end