git-deploy-ng 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/AGENTS.md +46 -0
  3. data/CHANGELOG.md +37 -0
  4. data/CONTRIBUTING.md +41 -3
  5. data/README.md +335 -0
  6. data/ROADMAP.md +15 -3
  7. data/bin/update-changelog +47 -0
  8. data/lib/git_deploy/changelog.rb +125 -0
  9. data/lib/git_deploy/configuration.rb +20 -5
  10. data/lib/git_deploy/generator.rb +39 -2
  11. data/lib/git_deploy/remote_path.rb +28 -0
  12. data/lib/git_deploy/ssh_methods.rb +15 -0
  13. data/lib/git_deploy/templates/generic/after_push.sh +15 -0
  14. data/lib/git_deploy/templates/generic/before_restart.sh +3 -0
  15. data/lib/git_deploy/templates/generic/restart.sh +2 -0
  16. data/lib/git_deploy/templates/php-composer/after_push.sh +15 -0
  17. data/lib/git_deploy/templates/php-composer/before_restart.sh +12 -0
  18. data/lib/git_deploy/templates/php-composer/restart.sh +13 -0
  19. data/lib/git_deploy/templates/rails-puma/after_push.sh +17 -0
  20. data/lib/git_deploy/templates/rails-puma/before_restart.rb +38 -0
  21. data/lib/git_deploy/templates/rails-puma/restart.sh +9 -0
  22. data/lib/git_deploy.rb +43 -5
  23. data/spec/changelog_spec.rb +110 -0
  24. data/spec/cli_spec.rb +9 -1
  25. data/spec/commands_spec.rb +22 -0
  26. data/spec/configuration_spec.rb +10 -0
  27. data/spec/documentation_spec.rb +15 -0
  28. data/spec/generator_spec.rb +75 -0
  29. data/spec/hooks_spec.rb +15 -0
  30. data/spec/remote_path_spec.rb +45 -0
  31. data/spec/setup_spec.rb +48 -1
  32. data/spec/workflows_spec.rb +57 -0
  33. metadata +25 -6
  34. data/README.markdown +0 -172
  35. /data/lib/git_deploy/templates/{after_push.sh → rails-passenger/after_push.sh} +0 -0
  36. /data/lib/git_deploy/templates/{before_restart.rb → rails-passenger/before_restart.rb} +0 -0
  37. /data/lib/git_deploy/templates/{restart.sh → rails-passenger/restart.sh} +0 -0
@@ -0,0 +1,125 @@
1
+ require 'date'
2
+
3
+ class GitDeploy
4
+ module Changelog
5
+ HEADER = <<~HEADER
6
+ # Changelog
7
+
8
+ All notable changes to this project will be documented in this file.
9
+
10
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
11
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
12
+
13
+ HEADER
14
+
15
+ UNRELEASED = '## [Unreleased]'
16
+
17
+ CATEGORY_FOR = {
18
+ 'feat' => 'Added',
19
+ 'fix' => 'Fixed',
20
+ 'docs' => 'Changed',
21
+ 'perf' => 'Changed'
22
+ }.freeze
23
+
24
+ CATEGORY_ORDER = ['Added', 'Changed', 'Fixed', 'Removed', 'Deprecated', 'Security'].freeze
25
+
26
+ module_function
27
+
28
+ def gemspec_version(path = 'git-deploy-ng.gemspec')
29
+ line = File.readlines(path).find { |l| l =~ /gem\.version\s*=/ }
30
+ line[/['"]([^'"]+)['"]/, 1]
31
+ end
32
+
33
+ def categorize(subject)
34
+ type = subject[/\A(\w+)(?:\([^)]+\))?!?:/, 1]
35
+ return nil unless type
36
+
37
+ CATEGORY_FOR[type]
38
+ end
39
+
40
+ def format_entry(subject)
41
+ body = subject.sub(/\A\w+(?:\([^)]+\))?!?:\s*/, '')
42
+ breaking = subject.include?('!:') || subject.match?(/\A\w+!:/)
43
+ entry = body.empty? ? subject : body[0].upcase + body[1..]
44
+ breaking ? "**BREAKING:** #{entry}" : entry
45
+ end
46
+
47
+ def group_commits(subjects)
48
+ groups = Hash.new { |h, k| h[k] = [] }
49
+ subjects.each do |subject|
50
+ next if subject.start_with?('Merge ')
51
+ category = categorize(subject) || next
52
+ groups[category] << format_entry(subject)
53
+ end
54
+ groups.each_value(&:uniq!)
55
+ groups
56
+ end
57
+
58
+ def render_section(version, date, groups)
59
+ lines = ["## [#{version}] - #{date}", '']
60
+ CATEGORY_ORDER.each do |category|
61
+ entries = groups[category]
62
+ next if entries.nil? || entries.empty?
63
+
64
+ lines << "### #{category}"
65
+ entries.sort.each { |entry| lines << "- #{entry}" }
66
+ lines << ''
67
+ end
68
+ lines.join("\n").rstrip + "\n"
69
+ end
70
+
71
+ def extract_section(content, version)
72
+ pattern = /^## \[#{Regexp.escape(version)}\][^\n]*\n(.*?)(?=^## \[|\z)/m
73
+ match = content.match(pattern)
74
+ return nil unless match
75
+
76
+ body = match[1].strip
77
+ body.empty? ? nil : body
78
+ end
79
+
80
+ def verify_version!(content, version)
81
+ section = extract_section(content, version)
82
+ abort "Error: CHANGELOG.md has no entry for version #{version}" if section.nil?
83
+ section
84
+ end
85
+
86
+ def update_content(content, version, date:, groups:)
87
+ section = render_section(version, date, groups)
88
+ if content.nil? || content.dup.force_encoding(Encoding::UTF_8).strip.empty?
89
+ return HEADER + "\n#{UNRELEASED}\n\n" + section + "\n"
90
+ end
91
+
92
+ content = content.dup.force_encoding(Encoding::UTF_8)
93
+
94
+ unless content.include?(UNRELEASED)
95
+ abort "Error: CHANGELOG.md is missing the #{UNRELEASED} heading"
96
+ end
97
+
98
+ if content.match?(/^## \[#{Regexp.escape(version)}\]/m)
99
+ abort "Error: CHANGELOG.md already has a section for version #{version}"
100
+ end
101
+
102
+ content.sub("#{UNRELEASED}\n", "#{UNRELEASED}\n\n#{section}\n")
103
+ end
104
+
105
+ def git_log_subjects(since_ref, until_ref: 'HEAD')
106
+ range = since_ref ? "#{since_ref}..#{until_ref}" : until_ref
107
+ `git log #{range} --pretty=format:%s --no-merges`.
108
+ force_encoding(Encoding::UTF_8).
109
+ split("\n", -1).
110
+ reject(&:empty?)
111
+ end
112
+
113
+ def latest_tag
114
+ tag = `git describe --tags --abbrev=0 2>/dev/null`.strip
115
+ tag.empty? ? nil : tag
116
+ end
117
+
118
+ def update_file(path, version, date: Date.today.iso8601, since_ref: latest_tag)
119
+ subjects = git_log_subjects(since_ref)
120
+ groups = group_commits(subjects)
121
+ content = File.exist?(path) ? File.read(path) : nil
122
+ File.write(path, update_content(content, version, date: date, groups: groups))
123
+ end
124
+ end
125
+ end
@@ -12,14 +12,21 @@ class GitDeploy
12
12
 
13
13
  def deploy_to
14
14
  @deploy_to ||= begin
15
- if remote_url.path.start_with? '/~/'
16
- remote_url.path[1..-1]
17
- else
18
- remote_url.path
19
- end
15
+ remote_url
16
+ normalize_deploy_path(
17
+ GitDeploy::RemotePath.deploy_path(remote_url_string) { remote_home }
18
+ )
20
19
  end
21
20
  end
22
21
 
22
+ def remote_url_string(remote = options[:remote])
23
+ remote_urls(remote).first
24
+ end
25
+
26
+ def normalize_deploy_path(path)
27
+ path.start_with?('/~/') ? path[1..-1] : path
28
+ end
29
+
23
30
  def remote_user
24
31
  @user ||= begin
25
32
  user = remote_url.user
@@ -56,6 +63,7 @@ class GitDeploy
56
63
  @remote_url[remote] ||= begin
57
64
  url = remote_urls(remote).first
58
65
  if url.nil?
66
+ require_remote! if remote.nil? || remote.to_s.strip.empty?
59
67
  abort "Error: Remote url not found for remote #{remote.inspect}"
60
68
  elsif url =~ /(^|@)github\.com\b/
61
69
  abort "Error: Remote url for #{remote.inspect} points to GitHub. Can't deploy there!"
@@ -91,5 +99,12 @@ class GitDeploy
91
99
  def tracked_for(branch)
92
100
  git_config['config branch.%s.merge' % normalize_branch(branch)]
93
101
  end
102
+
103
+ def require_remote!
104
+ remote = options[:remote]
105
+ if remote.nil? || remote.to_s.strip.empty?
106
+ abort "Error: Specify a remote with -r (e.g. git deploy setup -r production)"
107
+ end
108
+ end
94
109
  end
95
110
  end
@@ -3,10 +3,24 @@ require 'thor/group'
3
3
  class GitDeploy::Generator < Thor::Group
4
4
  include Thor::Actions
5
5
 
6
+ TEMPLATES = %w[rails-passenger rails-puma php-composer generic].freeze
7
+
8
+ class_option :template, :type => :string, :default => 'rails-passenger'
9
+
6
10
  def self.source_root
7
11
  File.expand_path('../templates', __FILE__)
8
12
  end
9
13
 
14
+ def verify_deploy_target
15
+ if File.exist?('deploy') && !File.directory?('deploy')
16
+ abort "Error: './deploy' exists but is a file. Remove or rename it before running init."
17
+ end
18
+
19
+ if File.directory?('deploy') && !Dir.empty?('deploy')
20
+ say "Warning: deploy/ already exists and is not empty. Existing files will not be overwritten.", :yellow
21
+ end
22
+ end
23
+
10
24
  def copy_main_hook
11
25
  copy_hook 'after_push.sh', 'deploy/after_push'
12
26
  end
@@ -16,13 +30,36 @@ class GitDeploy::Generator < Thor::Group
16
30
  end
17
31
 
18
32
  def copy_restart_callbacks
19
- copy_hook 'before_restart.rb', 'deploy/before_restart'
33
+ source = Dir[File.join(template_dir, 'before_restart.*')].first
34
+ abort "Error: No before_restart script in template #{options[:template].inspect}" unless source
35
+ copy_hook File.basename(source), 'deploy/before_restart'
20
36
  end
21
37
 
22
38
  private
23
39
 
40
+ def template_dir
41
+ @template_dir ||= begin
42
+ name = options[:template] || 'rails-passenger'
43
+ unless TEMPLATES.include?(name)
44
+ abort "Error: Unknown template #{name.inspect}. Choose from: #{TEMPLATES.join(', ')}"
45
+ end
46
+
47
+ root = File.expand_path(self.class.source_root)
48
+ dir = File.expand_path(name, root)
49
+ unless dir.start_with?("#{root}#{File::SEPARATOR}") && File.directory?(dir)
50
+ abort "Error: Unknown template #{name.inspect}. Choose from: #{TEMPLATES.join(', ')}"
51
+ end
52
+ dir
53
+ end
54
+ end
55
+
24
56
  def copy_hook(template, destination)
25
- copy_file template, destination
57
+ return if File.exist?(destination)
58
+
59
+ source = File.join(template_dir, template)
60
+ abort "Error: Missing #{template} in template #{options[:template].inspect}" unless File.file?(source)
61
+
62
+ copy_file source, destination
26
63
  chmod destination, 0744 unless File.executable? destination
27
64
  end
28
65
  end
@@ -0,0 +1,28 @@
1
+ require 'uri'
2
+
3
+ class GitDeploy
4
+ module RemotePath
5
+ SCP_PATH = %r{\A(?:[^@]+@)?[^:]+:(.+)\z}
6
+
7
+ def self.raw_path(url)
8
+ if url.nil? || url.to_s.strip.empty?
9
+ abort "Error: No deploy remote URL configured. Specify a remote with -r."
10
+ end
11
+
12
+ if url.match?(%r{\A[\w-]+://})
13
+ URI.parse(url).path
14
+ else
15
+ match = url.match(SCP_PATH)
16
+ match ? match[1] : url
17
+ end
18
+ end
19
+
20
+ def self.deploy_path(url, home: nil)
21
+ path = raw_path(url)
22
+ return path if path.start_with?('/', '~')
23
+
24
+ home = yield if home.nil? && block_given?
25
+ File.join(home, path)
26
+ end
27
+ end
28
+ end
@@ -1,5 +1,6 @@
1
1
  class GitDeploy
2
2
  module SSHMethods
3
+ require 'fileutils'
3
4
  private
4
5
 
5
6
  def sudo_cmd
@@ -91,6 +92,20 @@ class GitDeploy
91
92
  channels.each { |c| c.wait }
92
93
  end
93
94
 
95
+ def scp_download(files)
96
+ channels = []
97
+ files.each do |remote, local|
98
+ puts "FILE: [#{options[:remote]}] #{remote} -> [local] #{local}"
99
+ FileUtils.mkdir_p(File.dirname(local)) unless local.end_with?('/') || File.dirname(local) == '.'
100
+ channels << ssh_connection.scp.download(remote, local) unless options.noop?
101
+ end
102
+ channels.each { |c| c.wait }
103
+ end
104
+
105
+ def remote_home
106
+ @remote_home ||= run("echo $HOME").strip
107
+ end
108
+
94
109
  def ssh_connection
95
110
  @ssh ||= begin
96
111
  ssh = Net::SSH.start(host, remote_user, :port => remote_port || 22)
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env bash
2
+ set -e
3
+ oldrev=$1
4
+ newrev=$2
5
+
6
+ run() {
7
+ [ -x $1 ] && $1 $oldrev $newrev
8
+ }
9
+
10
+ echo files changed: $(git diff $oldrev $newrev --diff-filter=ACDMR --name-only | wc -l)
11
+
12
+ umask 002
13
+
14
+ run deploy/before_restart
15
+ run deploy/restart && run deploy/after_restart
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env bash
2
+ # Add pre-deploy steps here (dependencies, build, etc.)
3
+ exit 0
@@ -0,0 +1,2 @@
1
+ #!/bin/sh
2
+ echo "no restart configured — edit deploy/restart for your stack"
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env bash
2
+ set -e
3
+ oldrev=$1
4
+ newrev=$2
5
+
6
+ run() {
7
+ [ -x $1 ] && $1 $oldrev $newrev
8
+ }
9
+
10
+ echo files changed: $(git diff $oldrev $newrev --diff-filter=ACDMR --name-only | wc -l)
11
+
12
+ umask 002
13
+
14
+ run deploy/before_restart
15
+ run deploy/restart && run deploy/after_restart
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env bash
2
+ set -e
3
+ oldrev=$1
4
+ newrev=$2
5
+
6
+ if [ -f composer.json ]; then
7
+ composer install --no-dev --optimize-autoloader --no-interaction
8
+ fi
9
+
10
+ if [ -f artisan ]; then
11
+ php artisan migrate --force
12
+ fi
@@ -0,0 +1,13 @@
1
+ #!/bin/sh
2
+ # Reload PHP-FPM — adjust the service name for your server
3
+ if command -v systemctl >/dev/null 2>&1; then
4
+ for service in php-fpm php8.3-fpm php8.2-fpm; do
5
+ if sudo systemctl reload "$service" 2>/dev/null; then
6
+ echo "reloaded $service"
7
+ exit 0
8
+ fi
9
+ done
10
+ echo "Error: could not reload PHP-FPM (tried php-fpm, php8.3-fpm, php8.2-fpm)" >&2
11
+ exit 1
12
+ fi
13
+ echo "restarted PHP app"
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env bash
2
+ set -e
3
+ oldrev=$1
4
+ newrev=$2
5
+
6
+ run() {
7
+ [ -x $1 ] && $1 $oldrev $newrev
8
+ }
9
+
10
+ echo files changed: $(git diff $oldrev $newrev --diff-filter=ACDMR --name-only | wc -l)
11
+
12
+ umask 002
13
+
14
+ git submodule sync && git submodule update --init --recursive
15
+
16
+ run deploy/before_restart
17
+ run deploy/restart && run deploy/after_restart
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env ruby
2
+ oldrev, newrev = ARGV
3
+
4
+ def run(cmd)
5
+ exit($?.exitstatus) unless system "umask 002 && #{cmd}"
6
+ end
7
+
8
+ RAILS_ENV = ENV['RAILS_ENV'] || 'production'
9
+ use_bundler = File.file? 'Gemfile'
10
+ rake_cmd = use_bundler ? 'bundle exec rake' : 'rake'
11
+
12
+ if use_bundler
13
+ bundler_args = ['--deployment']
14
+ BUNDLE_WITHOUT = ENV['BUNDLE_WITHOUT'] || 'development:test'
15
+ bundler_args << '--without' << BUNDLE_WITHOUT unless BUNDLE_WITHOUT.empty?
16
+
17
+ run "bundle install #{bundler_args.join(' ')}"
18
+ end
19
+
20
+ if File.file? 'Rakefile'
21
+ tasks = []
22
+
23
+ if File.exist?('db/migrate')
24
+ num_migrations = `git diff #{oldrev} #{newrev} --diff-filter=A --name-only -z -- db/migrate`.split("\0").size
25
+ else
26
+ num_migrations = 0
27
+ end
28
+
29
+ tasks << "db:migrate" if num_migrations > 0
30
+
31
+ asset_paths = %w[app/assets app/javascript config/importmap.rb package.json yarn.lock]
32
+ changed_assets = `git diff #{oldrev} #{newrev} --name-only -z -- #{asset_paths.join(' ')}`.split("\0")
33
+ tasks << "assets:precompile" if changed_assets.size > 0
34
+
35
+ run "#{rake_cmd} #{tasks.join(' ')} RAILS_ENV=#{RAILS_ENV}" if tasks.any?
36
+ end
37
+
38
+ run "git clean -x -f -- public/assets tmp/cache/assets" if Dir.exist?('public/assets')
@@ -0,0 +1,9 @@
1
+ #!/bin/sh
2
+ mkdir -p tmp/pids
3
+ if [ -f tmp/pids/puma.pid ]; then
4
+ bundle exec pumactl -P tmp/pids/puma.pid restart
5
+ else
6
+ mkdir -p tmp
7
+ touch tmp/restart.txt
8
+ echo "restarting Puma app (tmp/restart.txt fallback — configure puma.pid for pumactl restart)"
9
+ fi
data/lib/git_deploy.rb CHANGED
@@ -6,23 +6,34 @@ class GitDeploy < Thor
6
6
  LOCAL_DIR = File.expand_path('..', __FILE__)
7
7
 
8
8
  require 'git_deploy/configuration'
9
+ require 'git_deploy/remote_path'
9
10
  require 'git_deploy/ssh_methods'
10
11
  include Configuration
11
12
  include SSHMethods
12
13
 
13
- class_option :remote, :aliases => '-r', :type => :string, :default => 'origin'
14
+ class_option :remote, :aliases => '-r', :type => :string
14
15
  class_option :noop, :aliases => '-n', :type => :boolean, :default => false
15
16
 
16
17
  desc "init", "Generates deployment customization scripts for your app"
18
+ method_option :template, :type => :string, :default => 'rails-passenger',
19
+ :desc => 'Template stack (rails-passenger, rails-puma, php-composer, generic)'
17
20
  def init
18
21
  require 'git_deploy/generator'
19
- Generator::start([])
22
+ Generator.start(['--template', options[:template]])
20
23
  end
21
24
 
22
25
  desc "setup", "Create the remote git repository and install push hooks for it"
23
26
  method_option :shared, :aliases => '-g', :type => :boolean, :default => false
24
27
  method_option :sudo, :aliases => '-s', :type => :boolean, :default => false
28
+ method_option :force, :aliases => '-f', :type => :boolean, :default => false
25
29
  def setup
30
+ require_remote!
31
+
32
+ remote_hook = "#{deploy_to}/.git/hooks/post-receive"
33
+ if run_test("[ -f #{remote_hook} ]") && !options[:force]
34
+ abort "Error: Remote already has a post-receive hook. Use --force to overwrite."
35
+ end
36
+
26
37
  sudo = options.sudo? ? "#{sudo_cmd} " : ''
27
38
 
28
39
  unless run_test("test -x #{deploy_to}")
@@ -35,17 +46,27 @@ class GitDeploy < Thor
35
46
  run [] do |cmd|
36
47
  cmd << "chmod g+ws #{deploy_to}" if options.shared?
37
48
  cmd << "cd #{deploy_to}"
38
- cmd << "git init #{options.shared? ? '--shared' : ''}"
39
- cmd << "sed -i'' -e 's/master/#{branch}/' .git/HEAD" unless branch == 'master'
49
+ unless run_test("[ -d #{deploy_to}/.git ]")
50
+ cmd << "git init #{options.shared? ? '--shared' : ''}"
51
+ cmd << "sed -i'' -e 's/master/#{branch}/' .git/HEAD" unless branch == 'master'
52
+ end
40
53
  cmd << "git config --bool receive.denyNonFastForwards false" if options.shared?
41
54
  cmd << "git config receive.denyCurrentBranch ignore"
42
55
  end
43
56
 
44
- invoke :hooks
57
+ invoke :hooks, [], force: options[:force]
45
58
  end
46
59
 
47
60
  desc "hooks", "Installs git hooks to the remote repository"
61
+ method_option :force, :aliases => '-f', :type => :boolean, :default => false
48
62
  def hooks
63
+ require_remote!
64
+
65
+ remote_hook = "#{deploy_to}/.git/hooks/post-receive"
66
+ if run_test("[ -f #{remote_hook} ]") && !options[:force]
67
+ abort "Error: Remote already has a post-receive hook. Use --force to overwrite."
68
+ end
69
+
49
70
  hooks_dir = File.join(LOCAL_DIR, 'hooks')
50
71
  remote_dir = "#{deploy_to}/.git/hooks"
51
72
 
@@ -55,11 +76,13 @@ class GitDeploy < Thor
55
76
 
56
77
  desc "restart", "Restarts the application on the server"
57
78
  def restart
79
+ require_remote!
58
80
  run "cd #{deploy_to} && deploy/restart 2>&1 | tee -a log/deploy.log"
59
81
  end
60
82
 
61
83
  desc "rerun", "Runs the `deploy/after_push' callback as if a new revision was pushed via git"
62
84
  def rerun
85
+ require_remote!
63
86
  run <<-BASH, :echo => false
64
87
  bash -e -c '
65
88
  cd '#{deploy_to}'
@@ -71,6 +94,7 @@ class GitDeploy < Thor
71
94
 
72
95
  desc "rollback", "Rolls back the checkout to before the last push"
73
96
  def rollback
97
+ require_remote!
74
98
  run <<-BASH, :echo => false
75
99
  bash -e -c '
76
100
  cd '#{deploy_to}'
@@ -87,12 +111,14 @@ class GitDeploy < Thor
87
111
  method_option :tail, :aliases => '-t', :type => :boolean, :default => false
88
112
  method_option :lines, :aliases => '-l', :type => :numeric, :default => 20
89
113
  def log(n = nil)
114
+ require_remote!
90
115
  tail_args = options.tail? ? '-f' : "-n#{n || options.lines}"
91
116
  run "tail #{tail_args} #{deploy_to}/log/deploy.log"
92
117
  end
93
118
 
94
119
  desc "upload <files>", "Copy local files to the remote app"
95
120
  def upload(*files)
121
+ require_remote!
96
122
  files = files.map { |f| Dir[f.strip] }.flatten
97
123
  abort "Error: Specify at least one file to upload" if files.empty?
98
124
 
@@ -101,4 +127,16 @@ class GitDeploy < Thor
101
127
  all
102
128
  }
103
129
  end
130
+
131
+ desc "download <files>", "Copy remote files from the app to local"
132
+ def download(*files)
133
+ require_remote!
134
+ abort "Error: Specify at least one file to download" if files.empty?
135
+
136
+ scp_download files.inject({}) { |all, file|
137
+ file = file.strip
138
+ all[File.join(deploy_to, file)] = file
139
+ all
140
+ }
141
+ end
104
142
  end
@@ -0,0 +1,110 @@
1
+ require 'spec_helper'
2
+ require 'git_deploy/changelog'
3
+
4
+ describe GitDeploy::Changelog do
5
+ describe '.categorize' do
6
+ it 'maps conventional commit types to Keep a Changelog headings' do
7
+ expect(described_class.categorize('feat: add download')).to eq('Added')
8
+ expect(described_class.categorize('fix: path resolution')).to eq('Fixed')
9
+ expect(described_class.categorize('docs: refresh README')).to eq('Changed')
10
+ expect(described_class.categorize('chore: deps')).to be_nil
11
+ end
12
+ end
13
+
14
+ describe '.format_entry' do
15
+ it 'capitalizes the subject and marks breaking changes' do
16
+ expect(described_class.format_entry('feat: add download')).
17
+ to eq('Add download')
18
+ expect(described_class.format_entry('feat!: require remote')).
19
+ to eq('**BREAKING:** Require remote')
20
+ end
21
+ end
22
+
23
+ describe '.group_commits' do
24
+ it 'groups and deduplicates entries by category' do
25
+ groups = described_class.group_commits([
26
+ 'feat: add download',
27
+ 'fix: path resolution',
28
+ 'Merge pull request #12',
29
+ 'chore: ignore tmp'
30
+ ])
31
+
32
+ expect(groups['Added']).to eq(['Add download'])
33
+ expect(groups['Fixed']).to eq(['Path resolution'])
34
+ expect(groups.key?('Changed')).to be false
35
+ end
36
+ end
37
+
38
+ describe '.render_section' do
39
+ it 'renders a Keep a Changelog version section' do
40
+ section = described_class.render_section('0.9.0', '2026-07-04', {
41
+ 'Added' => ['Download command'],
42
+ 'Fixed' => ['Remote path resolution']
43
+ })
44
+
45
+ expect(section).to include('## [0.9.0] - 2026-07-04')
46
+ expect(section).to include('### Added')
47
+ expect(section).to include('- Download command')
48
+ expect(section).to include('### Fixed')
49
+ end
50
+ end
51
+
52
+ describe '.update_content' do
53
+ let(:base) do
54
+ described_class::HEADER + "\n#{described_class::UNRELEASED}\n\n"
55
+ end
56
+
57
+ it 'inserts a new version section after Unreleased' do
58
+ updated = described_class.update_content(base, '0.9.0',
59
+ date: '2026-07-04',
60
+ groups: { 'Added' => ['Download command'] })
61
+
62
+ expect(updated).to include("## [Unreleased]\n\n## [0.9.0]")
63
+ expect(updated).to include('- Download command')
64
+ end
65
+
66
+ it 'aborts when the version section already exists' do
67
+ content = base + "## [0.9.0] - 2026-07-04\n\n"
68
+ expect {
69
+ described_class.update_content(content, '0.9.0',
70
+ date: '2026-07-04', groups: { 'Added' => ['X'] })
71
+ }.to output(/already has a section/).to_stderr.and raise_error(SystemExit)
72
+ end
73
+ end
74
+
75
+ describe '.extract_section' do
76
+ let(:content) do
77
+ <<~CHANGELOG
78
+ ## [Unreleased]
79
+
80
+ ## [0.9.0] - 2026-07-04
81
+
82
+ ### Added
83
+ - Download command
84
+
85
+ ## [0.8.0] - 2026-06-10
86
+ CHANGELOG
87
+ end
88
+
89
+ it 'returns the body for a version section' do
90
+ body = described_class.extract_section(content, '0.9.0')
91
+ expect(body).to include('### Added')
92
+ expect(body).to include('- Download command')
93
+ expect(body).not_to include('0.8.0')
94
+ end
95
+ end
96
+
97
+ describe '.verify_version!' do
98
+ it 'aborts when the version is missing' do
99
+ expect {
100
+ described_class.verify_version!("## [Unreleased]\n", '0.9.0')
101
+ }.to output(/no entry for version 0.9.0/).to_stderr.and raise_error(SystemExit)
102
+ end
103
+ end
104
+
105
+ describe '.gemspec_version' do
106
+ it 'reads the version from git-deploy-ng.gemspec' do
107
+ expect(described_class.gemspec_version).to eq('0.9.0')
108
+ end
109
+ end
110
+ end
data/spec/cli_spec.rb CHANGED
@@ -4,7 +4,15 @@ describe GitDeploy do
4
4
  it 'exposes the upstream CLI commands' do
5
5
  commands = described_class.all_commands.keys
6
6
  expect(commands).to include(
7
- 'init', 'setup', 'hooks', 'restart', 'rerun', 'rollback', 'log', 'upload'
7
+ 'init', 'setup', 'hooks', 'restart', 'rerun', 'rollback', 'log', 'upload', 'download'
8
8
  )
9
9
  end
10
+
11
+ it 'allows init without a remote name' do
12
+ Dir.mktmpdir do |dir|
13
+ Dir.chdir(dir) do
14
+ expect { described_class.start(%w[init]) }.not_to raise_error
15
+ end
16
+ end
17
+ end
10
18
  end