pdk 1.2.1 → 1.3.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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +300 -21
  3. data/lib/pdk/cli.rb +3 -2
  4. data/lib/pdk/cli/bundle.rb +0 -2
  5. data/lib/pdk/cli/convert.rb +25 -0
  6. data/lib/pdk/cli/exec.rb +4 -34
  7. data/lib/pdk/cli/exec_group.rb +2 -2
  8. data/lib/pdk/cli/module.rb +2 -3
  9. data/lib/pdk/cli/module/generate.rb +9 -4
  10. data/lib/pdk/cli/new/class.rb +1 -1
  11. data/lib/pdk/cli/new/module.rb +12 -9
  12. data/lib/pdk/cli/test/unit.rb +16 -7
  13. data/lib/pdk/cli/util.rb +47 -4
  14. data/lib/pdk/generate.rb +4 -4
  15. data/lib/pdk/{generators → generate}/defined_type.rb +1 -1
  16. data/lib/pdk/{generators → generate}/module.rb +47 -58
  17. data/lib/pdk/{generators → generate}/puppet_class.rb +1 -1
  18. data/lib/pdk/{generators → generate}/puppet_object.rb +1 -1
  19. data/lib/pdk/{generators → generate}/task.rb +1 -1
  20. data/lib/pdk/module/convert.rb +163 -0
  21. data/lib/pdk/module/metadata.rb +11 -3
  22. data/lib/pdk/module/templatedir.rb +81 -42
  23. data/lib/pdk/module/update_manager.rb +203 -0
  24. data/lib/pdk/tests/unit.rb +7 -6
  25. data/lib/pdk/util.rb +42 -1
  26. data/lib/pdk/util/bundler.rb +2 -2
  27. data/lib/pdk/util/git.rb +36 -0
  28. data/lib/pdk/util/version.rb +2 -1
  29. data/lib/pdk/validate.rb +3 -3
  30. data/lib/pdk/{validators → validate}/base_validator.rb +0 -0
  31. data/lib/pdk/{validators → validate}/metadata/metadata_json_lint.rb +1 -1
  32. data/lib/pdk/{validators → validate}/metadata/metadata_syntax.rb +2 -2
  33. data/lib/pdk/{validators → validate}/metadata/task_metadata_lint.rb +3 -3
  34. data/lib/pdk/{validators → validate}/metadata_validator.rb +4 -4
  35. data/lib/pdk/{validators → validate}/puppet/puppet_lint.rb +1 -1
  36. data/lib/pdk/{validators → validate}/puppet/puppet_syntax.rb +1 -1
  37. data/lib/pdk/{validators → validate}/puppet_validator.rb +3 -3
  38. data/lib/pdk/{validators → validate}/ruby/rubocop.rb +2 -2
  39. data/lib/pdk/{validators → validate}/ruby_validator.rb +2 -2
  40. data/lib/pdk/version.rb +2 -1
  41. metadata +36 -18
@@ -1,4 +1,4 @@
1
- require 'pdk/generators/puppet_object'
1
+ require 'pdk/generate/puppet_object'
2
2
 
3
3
  module PDK
4
4
  module Generate
@@ -223,7 +223,7 @@ module PDK
223
223
  @templates ||= [
224
224
  { type: 'CLI', url: @options[:'template-url'], allow_fallback: false },
225
225
  { type: 'metadata', url: module_metadata.data['template-url'], allow_fallback: true },
226
- { type: 'default', url: PDK::Generate::Module.default_template_url, allow_fallback: false },
226
+ { type: 'default', url: PDK::Util.default_template_url, allow_fallback: false },
227
227
  ]
228
228
  end
229
229
 
@@ -1,4 +1,4 @@
1
- require 'pdk/generators/puppet_object'
1
+ require 'pdk/generate/puppet_object'
2
2
 
3
3
  module PDK
4
4
  module Generate
@@ -0,0 +1,163 @@
1
+ require 'pdk/generate/module'
2
+ require 'pdk/module/update_manager'
3
+ require 'pdk/util'
4
+ require 'pdk/report'
5
+
6
+ module PDK
7
+ module Module
8
+ class Convert
9
+ def self.invoke(options)
10
+ update_manager = PDK::Module::UpdateManager.new
11
+ template_url = options.fetch(:'template-url', PDK::Util.default_template_url)
12
+
13
+ PDK::Module::TemplateDir.new(template_url, nil, false) do |templates|
14
+ new_metadata = update_metadata('metadata.json', templates.metadata, options)
15
+
16
+ if options[:noop] && new_metadata.nil?
17
+ update_manager.add_file('metadata.json', '')
18
+ elsif File.file?('metadata.json')
19
+ update_manager.modify_file('metadata.json', new_metadata)
20
+ else
21
+ update_manager.add_file('metadata.json', new_metadata)
22
+ end
23
+
24
+ templates.render do |file_path, file_content|
25
+ if File.exist? file_path
26
+ update_manager.modify_file(file_path, file_content)
27
+ else
28
+ update_manager.add_file(file_path, file_content)
29
+ end
30
+ end
31
+ end
32
+
33
+ unless update_manager.changes?
34
+ PDK::Report.default_target.puts(_('No changes required.'))
35
+ return
36
+ end
37
+
38
+ # Print the summary to the default target of reports
39
+ summary = get_summary(update_manager)
40
+ print_summary(summary)
41
+
42
+ # Generates the full convert report
43
+ full_report(update_manager) unless update_manager.changes[:modified].empty?
44
+
45
+ return if options[:noop]
46
+
47
+ unless options[:force]
48
+ PDK.logger.info _(
49
+ 'Module conversion is a potentially destructive action. ' \
50
+ 'Ensure that you have committed your module to a version control ' \
51
+ 'system or have a backup, and review the changes above before continuing.',
52
+ )
53
+ continue = PDK::CLI::Util.prompt_for_yes(_('Do you want to continue and make these changes to your module?'))
54
+ return unless continue
55
+ end
56
+
57
+ # Mark these files for removal after generating the report as these
58
+ # changes are not something that the user needs to review.
59
+ if update_manager.changed?('Gemfile')
60
+ update_manager.remove_file('Gemfile.lock')
61
+ update_manager.remove_file(File.join('.bundle', 'config'))
62
+ end
63
+
64
+ update_manager.sync_changes!
65
+
66
+ PDK::Util::Bundler.ensure_bundle! if update_manager.changed?('Gemfile')
67
+
68
+ print_result(summary)
69
+ end
70
+
71
+ def self.update_metadata(metadata_path, template_metadata, options = {})
72
+ if File.file?(metadata_path)
73
+ if File.readable?(metadata_path)
74
+ begin
75
+ metadata = PDK::Module::Metadata.from_file(metadata_path)
76
+ new_values = PDK::Module::Metadata::DEFAULTS.reject { |key, _| metadata.data.key?(key) }
77
+ metadata.update!(new_values)
78
+ rescue ArgumentError
79
+ metadata = PDK::Generate::Module.prepare_metadata(options) unless options[:noop] # rubocop:disable Metrics/BlockNesting
80
+ end
81
+ else
82
+ raise PDK::CLI::ExitWithError, _('Unable to convert module metadata; %{path} exists but it is not readable.') % {
83
+ path: metadata_path,
84
+ }
85
+ end
86
+ elsif File.exist?(metadata_path)
87
+ raise PDK::CLI::ExitWithError, _('Unable to convert module metadata; %{path} exists but it is not a file.') % {
88
+ path: metadata_path,
89
+ }
90
+ else
91
+ return nil if options[:noop]
92
+
93
+ project_dir = File.basename(Dir.pwd)
94
+ options[:module_name] = project_dir.split('-', 2).compact[-1]
95
+ options[:prompt] = false
96
+ options[:'skip-interview'] = true if options[:force]
97
+
98
+ metadata = PDK::Generate::Module.prepare_metadata(options)
99
+ end
100
+
101
+ metadata.update!(template_metadata)
102
+ metadata.to_json
103
+ end
104
+
105
+ def self.get_summary(update_manager)
106
+ summary = {}
107
+ update_manager.changes.each do |category, update_category|
108
+ updated_files = if update_category.respond_to?(:keys)
109
+ update_category.keys
110
+ else
111
+ update_category.map { |file| file[:path] }
112
+ end
113
+
114
+ summary[category] = updated_files
115
+ end
116
+
117
+ summary
118
+ end
119
+
120
+ def self.print_summary(summary)
121
+ footer = false
122
+
123
+ summary.keys.each do |category|
124
+ next if summary[category].empty?
125
+
126
+ PDK::Report.default_target.puts(_("\n%{banner}") % { banner: generate_banner("Files to be #{category}", 40) })
127
+ PDK::Report.default_target.puts(summary[category])
128
+ footer = true
129
+ end
130
+
131
+ PDK::Report.default_target.puts(_("\n%{banner}") % { banner: generate_banner('', 40) }) if footer
132
+ end
133
+
134
+ def self.print_result(summary)
135
+ PDK::Report.default_target.puts(_("\n%{banner}") % { banner: generate_banner('Convert completed', 40) })
136
+ summary_to_print = summary.map { |k, v| "#{v.length} files #{k}" unless v.empty? }.compact
137
+ PDK::Report.default_target.puts(_("\n%{summary}\n\n") % { summary: "#{summary_to_print.join(', ')}." })
138
+ end
139
+
140
+ def self.full_report(update_manager)
141
+ File.open('convert_report.txt', 'w') do |f|
142
+ f.write("/* Convert Report generated by PDK at #{Time.now} */")
143
+ update_manager.changes[:modified].each do |_, diff|
144
+ f.write("\n\n\n" + diff)
145
+ end
146
+ end
147
+ PDK::Report.default_target.puts(_("\nYou can find a report of differences in convert_report.txt.\n\n"))
148
+ end
149
+
150
+ def self.generate_banner(text, width = 80)
151
+ padding = width - text.length
152
+ banner = ''
153
+ padding_char = '-'
154
+
155
+ (padding / 2.0).ceil.times { banner << padding_char }
156
+ banner << text
157
+ (padding / 2.0).floor.times { banner << padding_char }
158
+
159
+ banner
160
+ end
161
+ end
162
+ end
163
+ end
@@ -7,14 +7,14 @@ module PDK
7
7
 
8
8
  DEFAULTS = {
9
9
  'name' => nil,
10
- 'version' => nil,
10
+ 'version' => '0.1.0',
11
11
  'author' => nil,
12
12
  'summary' => '',
13
13
  'license' => 'Apache-2.0',
14
14
  'source' => '',
15
15
  'project_page' => nil,
16
16
  'issues_url' => nil,
17
- 'dependencies' => Set.new.freeze,
17
+ 'dependencies' => [],
18
18
  'data_provider' => nil,
19
19
  'operatingsystem_support' => [
20
20
  {
@@ -34,7 +34,9 @@ module PDK
34
34
  'operatingsystemrelease' => ['2012 R2'],
35
35
  },
36
36
  ],
37
- 'requirements' => Set.new.freeze,
37
+ 'requirements' => [
38
+ { 'name' => 'puppet', 'version_requirement' => '>= 4.7.0 < 6.0.0' },
39
+ ],
38
40
  }.freeze
39
41
 
40
42
  def initialize(params = {})
@@ -71,6 +73,12 @@ module PDK
71
73
  JSON.pretty_generate(@data.dup.delete_if { |_key, value| value.nil? })
72
74
  end
73
75
 
76
+ def write!(path)
77
+ File.open(path, 'w') do |file|
78
+ file.puts to_json
79
+ end
80
+ end
81
+
74
82
  private
75
83
 
76
84
  # Do basic validation and parsing of the name parameter.
@@ -1,6 +1,7 @@
1
1
  require 'yaml'
2
+ require 'deep_merge'
2
3
  require 'pdk/util'
3
- require 'pdk/cli/exec'
4
+ require 'pdk/util/git'
4
5
  require 'pdk/cli/errors'
5
6
  require 'pdk/template_file'
6
7
 
@@ -21,7 +22,7 @@ module PDK
21
22
  # the template available on disk.
22
23
  #
23
24
  # @example Using a git repository as a template
24
- # PDK::Module::TemplateDir.new('https://github.com/puppetlabs/pdk-module-template') do |t|
25
+ # PDK::Module::TemplateDir.new('https://github.com/puppetlabs/pdk-templates') do |t|
25
26
  # t.render do |filename, content|
26
27
  # File.open(filename, 'w') do |file|
27
28
  # file.write(content)
@@ -36,7 +37,7 @@ module PDK
36
37
  # @raise [ArgumentError] (see #validate_module_template!)
37
38
  #
38
39
  # @api public
39
- def initialize(path_or_url, module_metadata = {})
40
+ def initialize(path_or_url, module_metadata = {}, init = false)
40
41
  if File.directory?(path_or_url)
41
42
  @path = path_or_url
42
43
  else
@@ -46,10 +47,19 @@ module PDK
46
47
  # @todo When switching this over to using rugged, cache the cloned
47
48
  # template repo in `%AppData%` or `$XDG_CACHE_DIR` and update before
48
49
  # use.
49
- temp_dir = PDK::Util.make_tmpdir_name('pdk-module-template')
50
+ temp_dir = PDK::Util.make_tmpdir_name('pdk-templates')
51
+ git_ref = PDK::Util.default_template_ref
50
52
 
51
- clone_result = PDK::CLI::Exec.git('clone', path_or_url, temp_dir)
52
- unless clone_result[:exit_code].zero?
53
+ clone_result = PDK::Util::Git.git('clone', path_or_url, temp_dir)
54
+
55
+ if clone_result[:exit_code].zero?
56
+ reset_result = PDK::Util::Git.git('-C', temp_dir, 'reset', '--hard', git_ref)
57
+ unless reset_result[:exit_code].zero?
58
+ PDK.logger.error reset_result[:stdout]
59
+ PDK.logger.error reset_result[:stderr]
60
+ raise PDK::CLI::FatalError, _("Unable to set git repository '%{repo}' to ref:'%{ref}'.") % { repo: temp_dir, ref: git_ref }
61
+ end
62
+ else
53
63
  PDK.logger.error clone_result[:stdout]
54
64
  PDK.logger.error clone_result[:stderr]
55
65
  raise PDK::CLI::FatalError, _("Unable to clone git repository '%{repo}' to '%{dest}'.") % { repo: path_or_url, dest: temp_dir }
@@ -59,7 +69,11 @@ module PDK
59
69
  @repo = path_or_url
60
70
  end
61
71
 
72
+ @init = init
62
73
  @moduleroot_dir = File.join(@path, 'moduleroot')
74
+ @moduleroot_init = File.join(@path, 'moduleroot_init')
75
+ @dirs = [@moduleroot_dir]
76
+ @dirs << @moduleroot_init if @init
63
77
  @object_dir = File.join(@path, 'object_templates')
64
78
  validate_module_template!
65
79
 
@@ -85,7 +99,7 @@ module PDK
85
99
  def metadata
86
100
  return {} unless @repo
87
101
 
88
- ref_result = PDK::CLI::Exec.git('--git-dir', File.join(@path, '.git'), 'describe', '--all', '--long', '--always')
102
+ ref_result = PDK::Util::Git.git('--git-dir', File.join(@path, '.git'), 'describe', '--all', '--long', '--always')
89
103
  if ref_result[:exit_code].zero?
90
104
  { 'template-url' => @repo, 'template-ref' => ref_result[:stdout].strip }
91
105
  else
@@ -107,12 +121,12 @@ module PDK
107
121
  #
108
122
  # @api public
109
123
  def render
110
- files_in_template.each do |template_file|
124
+ PDK::Module::TemplateDir.files_in_template(@dirs).each do |template_file, template_loc|
125
+ template_file = template_file.to_s
111
126
  PDK.logger.debug(_("Rendering '%{template}'...") % { template: template_file })
112
127
  dest_path = template_file.sub(%r{\.erb\Z}, '')
113
-
114
128
  begin
115
- dest_content = PDK::TemplateFile.new(File.join(@moduleroot_dir, template_file), configs: config_for(dest_path)).render
129
+ dest_content = PDK::TemplateFile.new(File.join(template_loc, template_file), configs: config_for(dest_path)).render
116
130
  rescue => e
117
131
  error_msg = _(
118
132
  "Failed to render template '%{template}'\n" \
@@ -120,7 +134,6 @@ module PDK
120
134
  ) % { template: template_file, exception: e.class, message: e.message }
121
135
  raise PDK::CLI::FatalError, error_msg
122
136
  end
123
-
124
137
  yield dest_path, dest_content
125
138
  end
126
139
  end
@@ -165,8 +178,6 @@ module PDK
165
178
  config_for(nil)
166
179
  end
167
180
 
168
- private
169
-
170
181
  # Validate the content of the template directory.
171
182
  #
172
183
  # @raise [ArgumentError] If the specified path is not a directory.
@@ -181,36 +192,46 @@ module PDK
181
192
  raise ArgumentError, _("The specified template '%{path}' is not a directory.") % { path: @path }
182
193
  end
183
194
 
184
- unless File.directory?(@moduleroot_dir) # rubocop:disable Style/GuardClause
195
+ unless File.directory?(@moduleroot_dir)
185
196
  raise ArgumentError, _("The template at '%{path}' does not contain a 'moduleroot/' directory.") % { path: @path }
186
197
  end
198
+
199
+ unless File.directory?(@moduleroot_init) # rubocop:disable Style/GuardClause
200
+ # rubocop:disable Metrics/LineLength
201
+ raise ArgumentError, _("The template at '%{path}' does not contain a 'moduleroot_init/' directory, which indicates you are using an older style of template. Before continuing please use the --template-url flag when running the pdk new commands to pass a new style template.") % { path: @path }
202
+ # rubocop:enable Metrics/LineLength
203
+ end
187
204
  end
188
205
 
189
206
  # Get a list of template files in the template directory.
190
207
  #
191
- # @return [Array[String]] An array of file names, relative to the
192
- # `moduleroot` directory.
208
+ # @return [Hash{String=>String}] A hash of key file names and
209
+ # value locations.
193
210
  #
194
- # @api private
195
- def files_in_template
196
- @files ||= begin
197
- template_paths = Dir.glob(File.join(@moduleroot_dir, '**', '*'), File::FNM_DOTMATCH).select do |template_path|
211
+ # @api public
212
+ def self.files_in_template(dirs)
213
+ temp_paths = []
214
+ dirlocs = []
215
+ dirs.each do |dir|
216
+ raise ArgumentError, _("The directory '%{dir}' doesn't exist") unless Dir.exist?(dir)
217
+ temp_paths += Dir.glob(File.join(dir, '**', '*'), File::FNM_DOTMATCH).select do |template_path|
198
218
  File.file?(template_path) && !File.symlink?(template_path)
219
+ dirlocs << dir
199
220
  end
200
-
201
- template_paths.map do |template_path|
202
- template_path.sub(%r{\A#{Regexp.escape(@moduleroot_dir)}#{Regexp.escape(File::SEPARATOR)}}, '')
221
+ temp_paths.map do |template_path|
222
+ template_path.sub!(%r{\A#{Regexp.escape(dir)}#{Regexp.escape(File::SEPARATOR)}}, '')
203
223
  end
204
224
  end
225
+ template_paths = Hash[temp_paths.zip dirlocs]
226
+ template_paths.delete('.')
227
+ template_paths.delete('spec')
228
+ template_paths.delete('spec/.')
229
+ template_paths
205
230
  end
206
231
 
207
232
  # Generate a hash of data to be used when rendering the specified
208
233
  # template.
209
234
  #
210
- # Read `config_defaults.yml` from the root of the template directory (if
211
- # it exists) build a hash of values by merging the value of the `:global`
212
- # key with the value of the key that matches `dest_path`.
213
- #
214
235
  # @param dest_path [String] The destination path of the file that the
215
236
  # data is for, relative to the root of the module.
216
237
  #
@@ -218,26 +239,44 @@ module PDK
218
239
  # `@configs` instance variable.
219
240
  #
220
241
  # @api private
221
- def config_for(dest_path)
242
+ def config_for(dest_path, sync_config_path = nil)
243
+ module_root = PDK::Util.module_root
244
+ sync_config_path ||= File.join(module_root, '.sync.yml') unless module_root.nil?
245
+ config_path = File.join(@path, 'config_defaults.yml')
246
+
222
247
  if @config.nil?
223
- config_path = File.join(@path, 'config_defaults.yml')
224
-
225
- if File.file?(config_path) && File.readable?(config_path)
226
- begin
227
- @config = YAML.safe_load(File.read(config_path), [], [], true)
228
- rescue StandardError => e
229
- PDK.logger.warn(_("'%{file}' is not a valid YAML file: %{message}") % { file: config_path, message: e.message })
230
- @config = {}
231
- end
232
- else
233
- @config = {}
234
- end
248
+ conf_defaults = read_config(config_path)
249
+ sync_config = read_config(sync_config_path) unless sync_config_path.nil?
250
+ @config = conf_defaults
251
+ @config.deep_merge!(sync_config) unless sync_config.nil?
235
252
  end
236
-
237
253
  file_config = @config.fetch(:global, {})
238
254
  file_config['module_metadata'] = @module_metadata
239
255
  file_config.merge!(@config.fetch(dest_path, {})) unless dest_path.nil?
240
- file_config
256
+ file_config.merge!(@config)
257
+ end
258
+
259
+ # Generates a hash of data from a given yaml file location.
260
+ #
261
+ # @param loc [String] The path of the yaml config file.
262
+ #
263
+ # @warn If the specified path is not a valid yaml file. Returns an empty Hash
264
+ # if so.
265
+ #
266
+ # @return [Hash] The data that has been read in from the given yaml file.
267
+ #
268
+ # @api private
269
+ def read_config(loc)
270
+ if File.file?(loc) && File.readable?(loc)
271
+ begin
272
+ YAML.safe_load(File.read(loc), [], [], true)
273
+ rescue StandardError => e
274
+ PDK.logger.warn(_("'%{file}' is not a valid YAML file: %{message}") % { file: config_path, message: e.message })
275
+ {}
276
+ end
277
+ else
278
+ {}
279
+ end
241
280
  end
242
281
  end
243
282
  end
@@ -0,0 +1,203 @@
1
+ require 'set'
2
+ require 'diff/lcs'
3
+ require 'diff/lcs/hunk'
4
+ require 'English'
5
+ require 'fileutils'
6
+
7
+ module PDK
8
+ module Module
9
+ class UpdateManager
10
+ # Initialises a blank UpdateManager object, which is used to store and
11
+ # process file additions/removals/modifications.
12
+ def initialize
13
+ @modified_files = Set.new
14
+ @added_files = Set.new
15
+ @removed_files = Set.new
16
+ @diff_cache = {}
17
+ end
18
+
19
+ # Store a pending modification to an existing file.
20
+ #
21
+ # @param path [String] The path to the file to be modified.
22
+ # @param content [String] The new content of the file.
23
+ def modify_file(path, content)
24
+ @modified_files << { path: path, content: content }
25
+ end
26
+
27
+ # Store a pending file addition.
28
+ #
29
+ # @param path [String] The path where the file will be created.
30
+ # @param content [String] The content of the new file.
31
+ def add_file(path, content)
32
+ @added_files << { path: path, content: content }
33
+ end
34
+
35
+ # Store a pending file removal.
36
+ #
37
+ # @param path [String] The path to the file to be removed.
38
+ def remove_file(path)
39
+ @removed_files << path
40
+ end
41
+
42
+ # Generate a summary of the changes that will be applied to the module.
43
+ #
44
+ # @raise (see #calculate_diffs)
45
+ # @return [Hash{Symbol => Set,Hash}] the summary of the pending changes.
46
+ def changes
47
+ calculate_diffs
48
+
49
+ {
50
+ added: @added_files,
51
+ removed: @removed_files,
52
+ modified: @diff_cache.reject { |_, value| value.nil? },
53
+ }
54
+ end
55
+
56
+ # Check if there are any pending changes to apply to the module.
57
+ #
58
+ # @raise (see #changes)
59
+ # @return [Boolean] true if there are changes to apply to the module.
60
+ def changes?
61
+ !changes[:added].empty? ||
62
+ !changes[:removed].empty? ||
63
+ changes[:modified].any? { |_, value| !value.nil? }
64
+ end
65
+
66
+ # Check if the update manager will change the specified file upon sync.
67
+ #
68
+ # @param path [String] The path to the file.
69
+ #
70
+ # @raise (see #changes)
71
+ # @return [Boolean] true if the file will be changed.
72
+ def changed?(path)
73
+ changes[:added].any? { |add| add[:path] == path } ||
74
+ changes[:removed].include?(path) ||
75
+ changes[:modified].keys.include?(path)
76
+ end
77
+
78
+ # Apply any pending changes stored in the UpdateManager to the module.
79
+ #
80
+ # @raise (see #calculate_diffs)
81
+ # @raise (see #write_file)
82
+ # @raise (see #unlink_file)
83
+ def sync_changes!
84
+ calculate_diffs
85
+
86
+ files_to_write = @added_files
87
+ files_to_write += @modified_files.reject { |file| @diff_cache[file[:path]].nil? }
88
+
89
+ files_to_write.each do |file|
90
+ write_file(file[:path], file[:content])
91
+ end
92
+
93
+ @removed_files.each do |file|
94
+ unlink_file(file)
95
+ end
96
+ end
97
+
98
+ private
99
+
100
+ # Loop through all the files to be modified and cache of unified diff of
101
+ # the changes to be made to each file.
102
+ #
103
+ # @raise [PDK::CLI::ExitWithError] if a file being modified isn't
104
+ # readable.
105
+ def calculate_diffs
106
+ @modified_files.each do |file|
107
+ next if @diff_cache.key?(file[:path])
108
+
109
+ unless File.readable?(file[:path])
110
+ raise PDK::CLI::ExitWithError, _("Unable to open '%{path}' for reading") % { path: file[:path] }
111
+ end
112
+
113
+ old_content = File.read(file[:path])
114
+ file_diff = unified_diff(file[:path], old_content, file[:content])
115
+ @diff_cache[file[:path]] = file_diff
116
+ end
117
+ end
118
+
119
+ # Write or overwrite a file with the specified content.
120
+ #
121
+ # @param path [String] The path to be written to.
122
+ # @param content [String] The data to be written to the file.
123
+ #
124
+ # @raise [PDK::CLI::ExitWithError] if the file is not writeable.
125
+ def write_file(path, content)
126
+ FileUtils.mkdir_p(File.dirname(path))
127
+ File.open(path, 'w') { |f| f.puts content }
128
+ rescue Errno::EACCES
129
+ raise PDK::CLI::ExitWithError, _("You do not have permission to write to '%{path}'") % { path: path }
130
+ end
131
+
132
+ # Remove a file from disk.
133
+ #
134
+ # Like FileUtils.rm_f, this method will not fail if the file does not
135
+ # exist. Unlink FileUtils.rm_f, this method will not blindly swallow all
136
+ # exceptions.
137
+ #
138
+ # @param path [String] The path to the file to be removed.
139
+ #
140
+ # @raise [PDK::CLI::ExitWithError] if the file could not be removed.
141
+ def unlink_file(path)
142
+ FileUtils.rm(path) if File.file?(path)
143
+ rescue => e
144
+ raise PDK::CLI::ExitWithError, _("Unable to remove '%{path}': %{message}") % {
145
+ path: path,
146
+ message: e.message,
147
+ }
148
+ end
149
+
150
+ # Generate a unified diff of the changes to be made to a file.
151
+ #
152
+ # @param path [String] The path to the file being diffed (only used to
153
+ # generate the diff header).
154
+ # @param old_content [String] The current content of the file.
155
+ # @param new_content [String] The new content of the file if the pending
156
+ # change is applied.
157
+ # @param lines_of_context [Integer] The maximum number of lines of
158
+ # context to include around the changed lines in the diff output
159
+ # (default: 3).
160
+ #
161
+ # @return [String] The unified diff of the pending changes to the file.
162
+ def unified_diff(path, old_content, new_content, lines_of_context = 3)
163
+ output = []
164
+
165
+ old_lines = old_content.split($INPUT_RECORD_SEPARATOR).map(&:chomp)
166
+ new_lines = new_content.split($INPUT_RECORD_SEPARATOR).map(&:chomp)
167
+
168
+ diffs = Diff::LCS.diff(old_lines, new_lines)
169
+
170
+ return nil if diffs.empty?
171
+
172
+ file_mtime = File.stat(path).mtime.localtime.strftime('%Y-%m-%d %H:%M:%S.%N %z')
173
+ now = Time.now.localtime.strftime('%Y-%m-%d %H:%M:%S.%N %z')
174
+
175
+ output << "--- #{path}\t#{file_mtime}"
176
+ output << "+++ #{path}.pdknew\t#{now}"
177
+
178
+ oldhunk = hunk = nil
179
+ file_length_difference = 0
180
+
181
+ diffs.each do |piece|
182
+ begin
183
+ hunk = Diff::LCS::Hunk.new(old_lines, new_lines, piece, lines_of_context, file_length_difference)
184
+ file_length_difference = hunk.file_length_difference
185
+
186
+ next unless oldhunk
187
+
188
+ # If the hunk overlaps with the oldhunk, merge them.
189
+ next if lines_of_context > 0 && hunk.merge(oldhunk)
190
+
191
+ output << oldhunk.diff(:unified)
192
+ ensure
193
+ oldhunk = hunk
194
+ end
195
+ end
196
+
197
+ output << oldhunk.diff(:unified)
198
+
199
+ output.join($INPUT_RECORD_SEPARATOR)
200
+ end
201
+ end
202
+ end
203
+ end