squared 0.0.4 → 0.0.6

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,306 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Squared
4
+ module Workspace
5
+ class Application
6
+ include Common
7
+ include Format
8
+ include ::Rake::DSL
9
+
10
+ TASK_BASE = {
11
+ build: [],
12
+ refresh: [],
13
+ depend: [],
14
+ outdated: [],
15
+ doc: [],
16
+ test: [],
17
+ copy: [],
18
+ clean: []
19
+ }
20
+ TASK_EXTEND = {}
21
+ WORKSPACE_KEYS = TASK_BASE.keys.freeze
22
+ private_constant :TASK_BASE, :TASK_EXTEND
23
+
24
+ class << self
25
+ def implement(*objs)
26
+ objs.each do |obj|
27
+ next unless obj < Project::Base
28
+
29
+ project_kind.unshift(obj)
30
+ obj.tasks&.each do |val|
31
+ TASK_BASE[val] ||= []
32
+ (TASK_EXTEND[val] ||= []).push(obj)
33
+ end
34
+ end
35
+ end
36
+
37
+ def find(path: nil, ref: nil)
38
+ if ref.is_a?(::Symbol) || ref.is_a?(::String)
39
+ ret = project_kind.find { |proj| proj.to_sym == ref.to_sym }
40
+ return ret if ret
41
+ end
42
+ project_kind.find { |proj| proj.is_a?(path) } if path
43
+ end
44
+
45
+ def to_s
46
+ super.to_s.match(/[^:]+$/)[0]
47
+ end
48
+
49
+ attr_reader :project_kind
50
+ end
51
+
52
+ @project_kind = []
53
+
54
+ attr_reader :main, :root, :home, :series, :theme, :warning
55
+ attr_accessor :exception, :pipe, :verbose
56
+
57
+ def initialize(main, *, common: true, exception: false, pipe: false, verbose: nil, **)
58
+ @main = main.to_s
59
+ @home = Pathname.pwd
60
+ @root = @home.parent
61
+ @series = Series.new(TASK_BASE, self)
62
+ @project = {}
63
+ @extensions = []
64
+ @script = {
65
+ group: {},
66
+ ref: {},
67
+ build: nil,
68
+ dev: nil,
69
+ prod: nil
70
+ }
71
+ @theme = common && @verbose ? __get__(:theme)[:workspace] : {}
72
+ @exception = exception.is_a?(String) ? !env(exception, ignore: '0').nil? : exception
73
+ @pipe = pipe.is_a?(String) ? !env(pipe, ignore: '0').nil? : pipe
74
+ @verbose = verbose.nil? ? !@pipe : verbose
75
+ @warning = true
76
+ end
77
+
78
+ def build(**kwargs)
79
+ return unless enabled?
80
+
81
+ @project.each_value do |proj|
82
+ next unless proj.enabled?
83
+
84
+ series.populate(proj)
85
+ proj.populate(**kwargs)
86
+ end
87
+
88
+ Application.project_kind.each { |obj| obj.populate(self, **kwargs) }
89
+ @extensions.each { |ext| __send__(ext, **kwargs) }
90
+
91
+ series[:refresh].clear if series[:refresh].all? { |val| val.end_with?(':build') }
92
+
93
+ task default: kwargs[:default] if series.has?(kwargs[:default]) && !task_defined?('default')
94
+
95
+ if series.has?('build')
96
+ init = ['depend', dev? && series.has?('refresh') ? 'refresh' : 'build']
97
+
98
+ task default: init[1] unless task_defined?('default')
99
+
100
+ if series.has?(init[0]) && !task_defined?('init')
101
+ desc 'init'
102
+ task init: init
103
+ end
104
+ end
105
+ series.finalize!(kwargs.fetch(:parallel, []))
106
+
107
+ yield self if block_given?
108
+ end
109
+
110
+ def run(script, group: nil, ref: nil)
111
+ script_command :run, script, group, ref
112
+ end
113
+
114
+ def depend(script, group: nil, ref: nil)
115
+ script_command :depend, script, group, ref
116
+ end
117
+
118
+ def clean(script, group: nil, ref: nil)
119
+ script_command :clean, script, group, ref
120
+ end
121
+
122
+ def doc(script, group: nil, ref: nil)
123
+ script_command :doc, script, group, ref
124
+ end
125
+
126
+ def test(script, group: nil, ref: nil)
127
+ script_command :test, script, group, ref
128
+ end
129
+
130
+ def add(name, path = nil, ref: nil, **kwargs)
131
+ path = root_path((path || name).to_s)
132
+ project = if !ref.is_a?(::Class)
133
+ Application.find(path: path, ref: ref)
134
+ elsif ref < Project::Base
135
+ ref
136
+ end
137
+ instance = (project || Project::Base).new(name, path, self, **kwargs)
138
+ @project[n = name.to_sym] = instance
139
+ __get__(:project)[n] = instance unless kwargs[:private]
140
+ self
141
+ end
142
+
143
+ def group(name, path, **kwargs)
144
+ root_path(path).children.map do |ent|
145
+ next unless (dir = Pathname.new(ent)).directory?
146
+
147
+ index = 0
148
+ basename = dir.basename.to_s.to_sym
149
+ override = kwargs.delete(basename)
150
+ while @project[basename]
151
+ index += 1
152
+ basename = :"#{basename}-#{index}"
153
+ end
154
+ [basename, dir, override]
155
+ end
156
+ .each do |basename, dir, override|
157
+ opts = kwargs.dup
158
+ opts.merge!(override) if override
159
+ add(basename, dir, group: name, **opts)
160
+ end
161
+ self
162
+ end
163
+
164
+ def compose(name, &blk)
165
+ namespace(name, &blk)
166
+ self
167
+ end
168
+
169
+ def apply(&blk)
170
+ instance_eval(&blk)
171
+ self
172
+ end
173
+
174
+ def style(name, *args, target: nil, empty: false)
175
+ data = nil
176
+ if target
177
+ as_a(target).each_with_index do |val, i|
178
+ if i == 0
179
+ break unless (data = __get__(:theme)[val.to_sym])
180
+ else
181
+ data = data[val.to_sym] ||= {}
182
+ end
183
+ end
184
+ return unless data
185
+ end
186
+ apply_style(data || theme, name, *args, empty: empty)
187
+ self
188
+ end
189
+
190
+ def script(group: nil, ref: nil)
191
+ if group || ref
192
+ (group && @script[:group][group.to_sym]) || (ref && @script[:ref][ref.to_sym])
193
+ else
194
+ @script[:build]
195
+ end
196
+ end
197
+
198
+ def env(key, equals: nil, ignore: nil, default: nil)
199
+ ret = ENV.fetch("#{key}_#{main.gsub(/[^\w]/, '_').upcase}", ENV[key]).to_s
200
+ return ret == equals.to_s unless equals.nil?
201
+
202
+ ret.empty? || (ignore && as_a(ignore).any? { |val| ret == val.to_s }) ? default : ret
203
+ end
204
+
205
+ def root_path(*args)
206
+ root.join(*args)
207
+ end
208
+
209
+ def home_path(*args)
210
+ home.join(*args)
211
+ end
212
+
213
+ def find_base(obj)
214
+ Application.project_kind.find { |proj| obj.instance_of?(proj) }
215
+ end
216
+
217
+ def to_s
218
+ root.to_s
219
+ end
220
+
221
+ def inspect
222
+ "#<#{self.class}: #{main} => #{self}>"
223
+ end
224
+
225
+ def enabled?
226
+ !@extensions.empty? || @project.any? { |_, proj| proj.enabled? }
227
+ end
228
+
229
+ def task_base?(key, val)
230
+ return val if task_defined?(key)
231
+
232
+ val if key == :refresh && series[:build].include?(val = "#{val.split(':').first}:build")
233
+ end
234
+
235
+ def task_include?(obj, key)
236
+ TASK_EXTEND.fetch(key, []).any? { |item| obj.is_a?(item) }
237
+ end
238
+
239
+ def task_defined?(key)
240
+ ::Rake::Task.task_defined?(key)
241
+ end
242
+
243
+ def dev?(script: nil, pat: nil, global: nil)
244
+ with?(:dev, script: script, pat: pat, global: global || (pat.nil? && global.nil?))
245
+ end
246
+
247
+ def prod?(script: nil, pat: nil, global: false)
248
+ return false if global && (@script[:dev] == true || !@script[:prod])
249
+
250
+ with?(:prod, script: script, pat: pat, global: global)
251
+ end
252
+
253
+ protected
254
+
255
+ def confirm(msg, agree: 'Y', cancel: 'N', default: nil, attempts: 5, timeout: 15)
256
+ require 'readline'
257
+ require 'timeout'
258
+ agree = /^#{agree}$/i if agree.is_a?(String)
259
+ cancel = /^#{cancel}$/i if cancel.is_a?(String)
260
+ Timeout.timeout(timeout) do
261
+ begin
262
+ while (ch = Readline.readline(msg, true))
263
+ ch = ch.chomp
264
+ ch = default if ch.empty?
265
+ case ch
266
+ when agree
267
+ return true
268
+ when cancel
269
+ return false
270
+ end
271
+ attempts -= 1
272
+ exit 1 unless attempts >= 0
273
+ end
274
+ rescue Interrupt
275
+ puts
276
+ exit 0
277
+ else
278
+ false
279
+ end
280
+ end
281
+ end
282
+
283
+ private
284
+
285
+ def script_command(task, val, group, ref)
286
+ if group
287
+ label = :group
288
+ items = as_a(group)
289
+ else
290
+ label = :ref
291
+ items = as_a(ref)
292
+ end
293
+ items.each { |name| (@script[label][name.to_sym] ||= {})[task] = val }
294
+ self
295
+ end
296
+
297
+ def with?(state, script: nil, pat: nil, global: false)
298
+ if global
299
+ pat = @script[state] if pat.nil?
300
+ script ||= @script[:build]
301
+ end
302
+ pat.is_a?(::Regexp) ? pat.match?(script) : pat == true
303
+ end
304
+ end
305
+ end
306
+ end
@@ -1,10 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'forwardable'
4
3
  require 'date'
4
+ require 'logger'
5
5
 
6
6
  module Squared
7
- module Repo
7
+ module Workspace
8
8
  module Project
9
9
  class Base
10
10
  include Common
@@ -12,7 +12,8 @@ module Squared
12
12
  include Shell
13
13
  include Task
14
14
  include ::Rake::DSL
15
- extend Forwardable
15
+
16
+ SEM_VER = /^(\d+)(\.)(\d+)(?:(\.)(\d+))?$/.freeze
16
17
 
17
18
  class << self
18
19
  include Common::Task
@@ -25,13 +26,16 @@ module Squared
25
26
  end
26
27
 
27
28
  def as_path(val)
28
- return val if val.is_a?(::Pathname)
29
-
30
- val.is_a?(::String) ? Pathname.new(val) : nil
29
+ case val
30
+ when ::Pathname
31
+ val
32
+ when ::String
33
+ Pathname.new(val)
34
+ end
31
35
  end
32
36
 
33
37
  def to_s
34
- /[^:]+$/.match(super.to_s)[0]
38
+ super.to_s.match(/[^:]+$/)[0]
35
39
  end
36
40
 
37
41
  def to_sym
@@ -42,15 +46,9 @@ module Squared
42
46
  @@print_order = 0
43
47
  @@tasks = {}
44
48
 
45
- alias __warn__ warn
46
-
47
- attr_reader :name, :project, :workspace, :group, :path, :logger, :theme
49
+ attr_reader :name, :project, :workspace, :group, :path, :log, :theme
48
50
  attr_accessor :warning
49
51
 
50
- protected :logger
51
-
52
- def_delegators :logger, :log, :<<, :debug, :info, :warn, :error, :fatal, :unknown
53
-
54
52
  def initialize(name, path, workspace, *, group: nil, log: nil, common: true, **kwargs)
55
53
  @name = name.to_s
56
54
  @path = workspace.root_path(path.to_s)
@@ -63,12 +61,14 @@ module Squared
63
61
  @output = [kwargs[:run], nil]
64
62
  @copy = kwargs[:copy]
65
63
  @clean = kwargs[:clean]
66
- @theme = if common
64
+ @warning = workspace.warning
65
+ @theme = if !workspace.verbose
66
+ {}
67
+ elsif common
67
68
  workspace.theme
68
69
  else
69
70
  __get__(:theme)[:project][to_sym] ||= {}
70
71
  end
71
- @warning = workspace.warning
72
72
  log = { file: log } unless log.is_a?(::Hash)
73
73
  if (logfile = env('LOG_FILE')).nil? && (auto = env('LOG_AUTO'))
74
74
  logfile = case auto
@@ -87,10 +87,10 @@ module Squared
87
87
  logfile.realdirpath
88
88
  rescue StandardError => e
89
89
  logfile = nil
90
- __warn__ e if @warning
90
+ warn e if @warning
91
91
  end
92
92
  end
93
- @logger = Logger.new(logfile, progname: @name, level: env('LOG_LEVEL') || log[:level] || Logger::INFO)
93
+ @log = Logger.new(logfile, progname: @name, level: env('LOG_LEVEL') || log[:level] || Logger::INFO)
94
94
  end
95
95
 
96
96
  def initialize_build(ref, **kwargs)
@@ -100,15 +100,13 @@ module Squared
100
100
  elsif @script && @output[0] != false
101
101
  @output[0] ||= @script[:run]
102
102
  end
103
+ @dev = kwargs.delete(:dev)
103
104
  if env('BUILD', suffix: 'DEV', equals: '0')
104
105
  @dev = false
105
- else
106
- @dev = kwargs.delete(:dev)
107
- if env('BUILD', suffix: 'DEV')
108
- @dev = true
109
- elsif @dev.nil?
110
- @dev = workspace.dev?
111
- end
106
+ elsif env('BUILD', suffix: 'DEV')
107
+ @dev = true
108
+ elsif @dev.nil?
109
+ @dev = workspace.dev?
112
110
  end
113
111
  end
114
112
 
@@ -125,12 +123,8 @@ module Squared
125
123
  def populate(*)
126
124
  namespace name do
127
125
  workspace.series.each_key do |key|
128
- case key
129
- when :build, :refresh, :depend, :outdated, :doc, :test, :copy, :clean
130
- next unless has?(key)
131
- else
132
- next unless workspace.task_defined?(self, key)
133
- end
126
+ next unless Application::WORKSPACE_KEYS.include?(key) ? has?(key) : workspace.task_include?(self, key)
127
+
134
128
  desc message(name, key)
135
129
  task key do
136
130
  __send__(key)
@@ -139,7 +133,7 @@ module Squared
139
133
  end
140
134
  end
141
135
 
142
- def build(*args)
136
+ def build(*args, sync: true)
143
137
  if args.empty?
144
138
  cmd, opts = @output
145
139
  opts &&= shell_escape(opts)
@@ -160,13 +154,13 @@ module Squared
160
154
  cmd = compose(opts)
161
155
  banner = env('REPO_BUILD') == 'verbose'
162
156
  end
163
- run(cmd, exception: workspace.exception, banner: banner)
157
+ run(cmd, exception: workspace.exception, banner: banner, sync: sync)
164
158
  end
165
159
 
166
160
  def refresh(*)
167
- build
161
+ build(sync: invoked_sync?('depend'))
168
162
  key = "#{name}:copy"
169
- if ::Rake::Task.task_defined?(key)
163
+ if workspace.task_defined?(key)
170
164
  invoke key
171
165
  else
172
166
  copy
@@ -174,34 +168,35 @@ module Squared
174
168
  end
175
169
 
176
170
  def depend(*)
177
- run(@depend, exception: workspace.exception) if @depend
171
+ run(@depend, exception: workspace.exception, sync: invoked_sync?('depend')) if @depend
178
172
  end
179
173
 
180
174
  def copy(*)
181
- run_s @copy
175
+ run_s(@copy, sync: invoked_sync?('copy'))
182
176
  end
183
177
 
184
178
  def doc
185
- build @doc if @doc
179
+ build(@doc, sync: invoked_sync?('doc')) if @doc
186
180
  end
187
181
 
188
182
  def test
189
- build @test if @test
183
+ build(@test, sync: invoked_sync?('test')) if @test
190
184
  end
191
185
 
192
186
  def clean
193
187
  return unless @clean
194
188
 
195
189
  if @clean.is_a?(::String)
196
- run_s @clean
190
+ run_s(@clean, sync: invoked_sync?('clean'))
197
191
  else
198
192
  @clean.each do |val|
199
- if (val = val.to_s).match?(%r{[\\/]$})
193
+ val = val.to_s
194
+ if val =~ %r{[\\/]$}
200
195
  dir = Pathname.new(val)
201
196
  dir = base_path(dir) unless dir.absolute?
202
197
  next unless dir.directory?
203
198
 
204
- warn "rm -rf #{dir}"
199
+ log.warn "rm -rf #{dir}"
205
200
  dir.rmtree(verbose: true)
206
201
  else
207
202
  files = val.include?('*') ? Dir[base_path(val)] : [base_path(val)]
@@ -209,7 +204,7 @@ module Squared
209
204
  begin
210
205
  File.delete(file) if File.file?(file)
211
206
  rescue StandardError => e
212
- error e
207
+ log.error e
213
208
  end
214
209
  end
215
210
  end
@@ -246,7 +241,7 @@ module Squared
246
241
  end
247
242
 
248
243
  def refresh?
249
- build? && (copy? || ::Rake::Task.task_defined?("#{name}:copy"))
244
+ build? && (copy? || workspace.task_defined?("#{name}:copy"))
250
245
  end
251
246
 
252
247
  def depend?
@@ -271,36 +266,36 @@ module Squared
271
266
 
272
267
  protected
273
268
 
274
- def run(cmd = @session, exception: false, banner: true, req: nil)
269
+ def run(cmd = @session, exception: false, banner: true, sync: true, req: nil)
275
270
  return if req && !base_path(req).exist?
276
271
 
277
272
  cmd = close_session(cmd)
278
- info cmd
279
- print_item format_banner(cmd, banner: banner)
273
+ log.info cmd
274
+ print_item format_banner(cmd, banner: banner) if sync
280
275
  begin
281
276
  shell(cmd, chdir: path, exception: exception)
282
277
  rescue StandardError => e
283
- error e
278
+ log.error e
284
279
  raise
285
280
  end
286
281
  end
287
282
 
288
- def run_s(cmd)
289
- run(cmd, exception: workspace.exception, banner: verbose?) if cmd.is_a?(::String)
283
+ def run_s(cmd, **kwargs)
284
+ run(cmd, exception: workspace.exception, banner: verbose?, **kwargs) if cmd.is_a?(::String)
290
285
  end
291
286
 
292
- def env(key, suffix: nil, equals: nil, strict: false)
293
- alt = "#{key}_#{name.upcase}"
294
- ret = if suffix
295
- ENV.fetch(([alt] + as_a(suffix)).join('_'), '')
296
- else
297
- ENV.fetch(alt, strict ? '' : ENV[key]).to_s
298
- end
299
- if !equals.nil?
300
- ret == equals.to_s
301
- elsif !ret.empty? && ret != '0'
302
- ret
287
+ def env(key, equals: nil, ignore: ['0'].freeze, default: nil, suffix: nil, strict: false)
288
+ a = "#{key}_#{name.upcase}"
289
+ b = ''
290
+ if suffix
291
+ a = [a, suffix].flatten.join('_')
292
+ elsif !strict
293
+ b = ENV.fetch(key, '')
303
294
  end
295
+ ret = ENV.fetch(a, b)
296
+ return ret == equals.to_s unless equals.nil?
297
+
298
+ ret.empty? || as_a(ignore).any? { |val| ret == val.to_s } ? default : ret
304
299
  end
305
300
 
306
301
  def session(*cmd, options: nil)
@@ -312,8 +307,8 @@ module Squared
312
307
 
313
308
  def close_session(cmd)
314
309
  return cmd unless cmd.respond_to?(:done)
315
- raise ArgumentError, message('none were provided', hint: name) if cmd.empty?
316
310
 
311
+ raise_error('none were provided', hint: name) if cmd.empty?
317
312
  @session = nil if cmd == @session
318
313
  cmd.done
319
314
  end
@@ -324,26 +319,40 @@ module Squared
324
319
  puts val unless val.empty? || (val.size == 1 && val.first.nil?)
325
320
  end
326
321
 
327
- def print_banner(*lines, styles: nil, pad: 0, first: false)
322
+ def print_banner(*lines, styles: theme[:banner], border: theme[:border])
323
+ pad = 0
324
+ if styles
325
+ if styles.any? { |s| s.to_s.end_with?('!') }
326
+ pad = 1
327
+ elsif styles.size <= 1
328
+ styles = [:bold] + styles
329
+ end
330
+ end
328
331
  n = max_width(lines)
329
332
  ch = ' ' * pad
330
333
  index = -1
331
334
  out = lines.map do |val|
332
335
  index += 1
333
336
  val = ch + val.ljust(n - (pad * 2)) + ch
334
- if styles && (!first || index == 0)
337
+ if styles && (pad == 1 || index == 0)
335
338
  sub_style(val, *styles)
336
339
  else
337
340
  val
338
341
  end
339
342
  end
340
- out << ('-' * n)
343
+ out << sub_style('-' * n, styles: border)
341
344
  out.join("\n")
342
345
  end
343
346
 
344
- def print_footer(*lines)
347
+ def print_footer(*lines, sub: nil, border: theme[:border])
345
348
  n = max_width(lines)
346
- ['-' * n, *lines.map { |val| val.ljust(n) }].join("\n")
349
+ sub = as_a(sub)
350
+ lines.map! do |val|
351
+ s = val.ljust(n)
352
+ sub.each { |h| s = sub_style(s, **h) }
353
+ s
354
+ end
355
+ [sub_style('-' * n, styles: border), *lines].join("\n")
347
356
  end
348
357
 
349
358
  def format_desc(action, flag, opts = nil, req: 'opts*')
@@ -355,20 +364,12 @@ module Squared
355
364
  message(name, action, opts ? "#{flag}[#{opts}]" : flag)
356
365
  end
357
366
 
358
- def format_banner(cmd, banner: true, multitask: false)
367
+ def format_banner(cmd, banner: true, multiple: false)
359
368
  return unless banner
360
369
 
361
370
  if verbose?
362
- pad = 0
363
- if (args = theme[:banner])
364
- if args.any? { |s| s.to_s.end_with?('!') }
365
- pad = 1
366
- elsif args.size <= 1
367
- args = [:bold] + args
368
- end
369
- end
370
- print_banner(cmd.sub(/^\S+/, &:upcase), path.to_s, styles: args, pad: pad, first: pad == 0)
371
- elsif multitask && workspace.multitask?
371
+ print_banner(cmd.sub(/^\S+/, &:upcase), path.to_s)
372
+ elsif multiple && workspace.series.multiple?
372
373
  "## #{path} ##"
373
374
  end
374
375
  end
@@ -386,17 +387,17 @@ module Squared
386
387
  return unless (val = args[key]).nil? || (pat && !val.match?(pat))
387
388
 
388
389
  @session = nil
389
- raise ArgumentError, message(action, "#{flag}[#{key}]", hint: val.nil? ? 'missing' : 'invalid')
390
+ raise_error(action, "#{flag}[#{key}]", hint: val.nil? ? 'missing' : 'invalid')
390
391
  elsif args.is_a?(::Array)
391
392
  return unless args.empty?
392
393
 
393
394
  @session = nil
394
- raise ArgumentError, message(action, "#{flag}[]", hint: 'empty')
395
+ raise_error(action, "#{flag}[]", hint: 'empty')
395
396
  end
396
397
  return unless action.to_s.empty?
397
398
 
398
399
  @session = nil
399
- raise ArgumentError, message('parameter', flag, hint: 'missing')
400
+ raise_error('parameter', flag, hint: 'missing')
400
401
  end
401
402
 
402
403
  def verbose?
@@ -408,13 +409,12 @@ module Squared
408
409
  end
409
410
 
410
411
  def invoked_sync?(action)
411
- return true if invoked?(sync = "#{action}:sync")
412
+ return true if workspace.series.sync?("#{action}:sync")
412
413
 
413
- missing = ->(val) { ::Rake::Task.tasks.none? { |item| item.name == val } }
414
414
  check = lambda do |val|
415
415
  if invoked?(val)
416
- missing.("#{val}:sync")
417
- elsif invoked?("#{val}:sync")
416
+ !workspace.task_defined?("#{val}:sync")
417
+ elsif workspace.series.sync?("#{val}:sync")
418
418
  true
419
419
  end
420
420
  end
@@ -426,7 +426,7 @@ module Squared
426
426
  ret = check.("#{action}:#{base.to_sym}")
427
427
  return ret unless ret.nil?
428
428
  end
429
- invoked?("#{name}:#{action}") && (!invoked?(action) || missing.(sync))
429
+ invoked?("#{name}:#{action}") && (!invoked?(action) || !workspace.task_defined?("#{action}:sync"))
430
430
  end
431
431
 
432
432
  private