pdk 1.2.1 → 1.3.0

Sign up to get free protection for your applications and to get access to all the features.
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