ruby_raider 4.1.0 → 4.2.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.
- checksums.yaml +4 -4
- data/.github/workflows/e2e_tests.yml +3 -0
- data/README.md +52 -53
- data/RELEASE.md +14 -1
- data/lib/commands/schedule_commands.rb +84 -0
- data/lib/generators/templates/common/git_ignore.tt +1 -0
- data/lib/ruby_raider/runtime/retry_reset.rb +32 -0
- data/lib/ruby_raider/runtime/rspec.rb +1 -0
- data/lib/ruby_raider/runtime/show_browser.rb +53 -0
- data/lib/ruby_raider/runtime.rb +2 -0
- data/lib/ruby_raider.rb +22 -7
- data/lib/utilities/schedules/adapter.rb +22 -0
- data/lib/utilities/schedules/base.rb +27 -0
- data/lib/utilities/schedules/kind.rb +74 -0
- data/lib/utilities/schedules/legacy.rb +196 -0
- data/lib/utilities/schedules/legacy_stub.rb +62 -0
- data/lib/utilities/{flows → schedules}/next_run.rb +2 -2
- data/lib/utilities/{flows → schedules}/notifier.rb +6 -6
- data/lib/utilities/{flows → schedules}/os/command_runner.rb +1 -1
- data/lib/utilities/{flows → schedules}/os/cron.rb +27 -17
- data/lib/utilities/{flows → schedules}/os/launchd.rb +32 -14
- data/lib/utilities/{flows → schedules}/os/linux.rb +23 -5
- data/lib/utilities/{flows → schedules}/os/systemd.rb +33 -17
- data/lib/utilities/{flows → schedules}/os/task_scheduler.rb +36 -20
- data/lib/utilities/schedules/os_enumerator.rb +30 -0
- data/lib/utilities/schedules/project.rb +70 -0
- data/lib/utilities/schedules/rule.rb +162 -0
- data/lib/utilities/schedules/run_lock.rb +160 -0
- data/lib/utilities/schedules/run_log.rb +122 -0
- data/lib/utilities/schedules/run_report.rb +139 -0
- data/lib/utilities/schedules/run_wrapper.rb +216 -0
- data/lib/utilities/schedules/schedule.rb +89 -0
- data/lib/utilities/schedules/schedule_store.rb +53 -0
- data/lib/utilities/schedules/scheduler.rb +178 -0
- data/lib/utilities/schedules/scheduler_steps.rb +69 -0
- data/lib/utilities/schedules/selection.rb +133 -0
- data/lib/utilities/schedules/spawn.rb +68 -0
- data/lib/utilities/{flows/schedule_store.rb → schedules/store.rb} +17 -13
- data/lib/version +1 -1
- data/ruby_raider.gemspec +1 -0
- data/sig/commands/schedule_commands.rbs +12 -0
- data/sig/ruby_raider/runtime/retry_reset.rbs +7 -0
- data/sig/ruby_raider/runtime/show_browser.rbs +27 -0
- data/sig/ruby_raider.rbs +4 -0
- data/sig/utilities/schedules/adapter.rbs +5 -0
- data/sig/utilities/schedules/base.rbs +15 -0
- data/sig/utilities/schedules/kind.rbs +32 -0
- data/sig/utilities/schedules/legacy.rbs +45 -0
- data/sig/utilities/schedules/legacy_stub.rbs +15 -0
- data/sig/utilities/{flows → schedules}/next_run.rbs +3 -3
- data/sig/utilities/{flows → schedules}/notifier.rbs +1 -1
- data/sig/utilities/{flows → schedules}/os/command_runner.rbs +1 -1
- data/sig/utilities/{flows → schedules}/os/cron.rbs +7 -7
- data/sig/utilities/{flows → schedules}/os/launchd.rbs +7 -6
- data/sig/utilities/{flows → schedules}/os/linux.rbs +10 -4
- data/sig/utilities/{flows → schedules}/os/systemd.rbs +8 -7
- data/sig/utilities/{flows → schedules}/os/task_scheduler.rbs +13 -10
- data/sig/utilities/schedules/os_enumerator.rbs +12 -0
- data/sig/utilities/schedules/project.rbs +22 -0
- data/sig/utilities/{flows/schedule.rbs → schedules/rule.rbs} +23 -12
- data/sig/utilities/schedules/run_lock.rbs +29 -0
- data/sig/utilities/schedules/run_log.rbs +33 -0
- data/sig/utilities/schedules/run_report.rbs +34 -0
- data/sig/utilities/schedules/run_wrapper.rbs +45 -0
- data/sig/utilities/schedules/schedule.rbs +27 -0
- data/sig/utilities/schedules/schedule_store.rbs +21 -0
- data/sig/utilities/{flows → schedules}/scheduler.rbs +22 -15
- data/sig/utilities/schedules/scheduler_steps.rbs +22 -0
- data/sig/utilities/schedules/selection.rbs +36 -0
- data/sig/utilities/schedules/spawn.rbs +10 -0
- data/sig/utilities/{flows/schedule_store.rbs → schedules/store.rbs} +14 -9
- data/spec/commands/schedule_commands_spec.rb +284 -0
- data/spec/integration/content/common_content_spec.rb +8 -2
- data/spec/integration/content/gemfile_content_spec.rb +2 -2
- data/spec/integration/end_to_end_spec.rb +0 -63
- data/spec/runtime/retry_reset_spec.rb +112 -0
- data/spec/runtime/show_browser_project_spec.rb +70 -0
- data/spec/runtime/show_browser_spec.rb +101 -0
- data/spec/utilities/schedules/kind_spec.rb +81 -0
- data/spec/utilities/schedules/legacy_spec.rb +433 -0
- data/spec/utilities/schedules/legacy_stub_spec.rb +144 -0
- data/spec/utilities/{flows → schedules}/next_run_spec.rb +5 -5
- data/spec/utilities/{flows → schedules}/notifier_spec.rb +3 -4
- data/spec/utilities/{flows → schedules}/os/command_runner_spec.rb +2 -3
- data/spec/utilities/{flows → schedules}/os/cron_spec.rb +34 -29
- data/spec/utilities/{flows → schedules}/os/launchd_spec.rb +42 -29
- data/spec/utilities/{flows → schedules}/os/linux_spec.rb +27 -13
- data/spec/utilities/schedules/os/list_ids_spec.rb +202 -0
- data/spec/utilities/{flows → schedules}/os/systemd_spec.rb +35 -31
- data/spec/utilities/{flows → schedules}/os/task_scheduler_spec.rb +40 -34
- data/spec/utilities/schedules/os_entry_spec.rb +54 -0
- data/spec/utilities/schedules/os_enumerator_spec.rb +57 -0
- data/spec/utilities/schedules/project_spec.rb +125 -0
- data/spec/utilities/schedules/run_lock_spec.rb +143 -0
- data/spec/utilities/schedules/run_log_spec.rb +83 -0
- data/spec/utilities/schedules/run_report_spec.rb +150 -0
- data/spec/utilities/schedules/run_wrapper_integration_spec.rb +166 -0
- data/spec/utilities/schedules/run_wrapper_spec.rb +430 -0
- data/spec/utilities/schedules/schedule_spec.rb +120 -0
- data/spec/utilities/schedules/schedule_store_spec.rb +151 -0
- data/spec/utilities/schedules/scheduler_launchd_spec.rb +77 -0
- data/spec/utilities/schedules/scheduler_spec.rb +445 -0
- data/spec/utilities/schedules/selection_spec.rb +176 -0
- data/spec/utilities/schedules/spawn_spec.rb +104 -0
- metadata +106 -53
- data/lib/commands/flow_commands.rb +0 -104
- data/lib/generators/templates/flows/flow_helper.tt +0 -42
- data/lib/generators/templates/flows/run.tt +0 -332
- data/lib/utilities/flows/project.rb +0 -170
- data/lib/utilities/flows/run_log.rb +0 -61
- data/lib/utilities/flows/runner.rb +0 -206
- data/lib/utilities/flows/schedule.rb +0 -221
- data/lib/utilities/flows/scheduler.rb +0 -187
- data/sig/commands/flow_commands.rbs +0 -13
- data/sig/utilities/flows/project.rbs +0 -46
- data/sig/utilities/flows/run_log.rbs +0 -22
- data/sig/utilities/flows/runner.rbs +0 -35
- data/spec/commands/flow_commands_spec.rb +0 -128
- data/spec/generators/flow_helper_spec.rb +0 -40
- data/spec/integration/flows/flow_project.rb +0 -62
- data/spec/integration/flows/run_command_spec.rb +0 -91
- data/spec/integration/flows/run_lifecycle_spec.rb +0 -383
- data/spec/utilities/flows/project_spec.rb +0 -178
- data/spec/utilities/flows/run_log_spec.rb +0 -47
- data/spec/utilities/flows/runner_spec.rb +0 -313
- data/spec/utilities/flows/schedule_spec.rb +0 -233
- data/spec/utilities/flows/schedule_store_spec.rb +0 -137
- data/spec/utilities/flows/scheduler_spec.rb +0 -329
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rbconfig'
|
|
4
|
+
require_relative 'rule'
|
|
5
|
+
require_relative 'selection'
|
|
6
|
+
require_relative 'project'
|
|
7
|
+
|
|
8
|
+
module RubyRaider
|
|
9
|
+
module Schedules
|
|
10
|
+
# One scheduled test run: the shared rule (Rule) plus which tests run and how (show_browser). The OS
|
|
11
|
+
# entry runs `raider schedule run NAME` only; the selection, retries, notify and show_browser are
|
|
12
|
+
# read from schedules.yml at run time. Immutable.
|
|
13
|
+
# :reek:TooManyInstanceVariables { max_instance_variables: 14 } -- the fields of a schedules.yml entry
|
|
14
|
+
class Schedule < Rule
|
|
15
|
+
KIND = Kind::TESTS
|
|
16
|
+
KEYS = %w[name every at on retries notify show_browser selection os_id ruby bundle gem_env created_at].freeze
|
|
17
|
+
LINUX = /linux/
|
|
18
|
+
NO_BROWSER_ON_LINUX = "Show the browser isn't available for scheduled runs on Linux"
|
|
19
|
+
|
|
20
|
+
attr_reader :show_browser, :selection
|
|
21
|
+
|
|
22
|
+
def self.kind = KIND
|
|
23
|
+
|
|
24
|
+
# Linux schedules run from systemd or cron, with no display: show_browser is refused there
|
|
25
|
+
def self.build(project:, name:, every:, at: nil, on: nil, retries: 0, notify: false, show_browser: false, selection: Selection.all,
|
|
26
|
+
now: Time.now, host_os: RbConfig::CONFIG['host_os'])
|
|
27
|
+
raise Error, NO_BROWSER_ON_LINUX if show_browser == true && host_os.match?(LINUX)
|
|
28
|
+
|
|
29
|
+
root = project.root
|
|
30
|
+
new(name: name, every: every, at: at, on: on, retries: retries, notify: notify, show_browser: show_browser, selection: selection,
|
|
31
|
+
os_id: os_id_for(root, name), ruby: RbConfig.ruby, bundle: bundle_path, gem_env: captured_gem_env,
|
|
32
|
+
project_root: root, created_at: now.getutc.iso8601)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# A schedules.yml entry; raises Error when it is not one this class wrote. A spec file that is gone
|
|
36
|
+
# still loads (selection.missing_file names it).
|
|
37
|
+
def self.from_h(hash, project_root, host_os: RbConfig::CONFIG['host_os'])
|
|
38
|
+
raise Error, 'A schedule entry must be a mapping' unless hash.is_a?(Hash)
|
|
39
|
+
|
|
40
|
+
fields = KEYS.to_h { |key| [key.to_sym, hash[key]] }
|
|
41
|
+
fields[:selection] = Selection.from_h(hash['selection'], root: project_root, host_os: host_os)
|
|
42
|
+
schedule = new(**fields, project_root: project_root)
|
|
43
|
+
# Strict: an entry copied from another project, or a moved project, is not this project's entry
|
|
44
|
+
raise Error, "Invalid os_id #{schedule.os_id.inspect} for #{schedule.name} in #{project_root}" unless schedule.os_id == os_id_for(project_root, schedule.name)
|
|
45
|
+
|
|
46
|
+
schedule
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def initialize(name:, every:, at:, on:, retries:, selection:, os_id:, ruby:, bundle:, gem_env:, project_root:, created_at:, notify: false,
|
|
50
|
+
show_browser: false)
|
|
51
|
+
super()
|
|
52
|
+
@name = checked_name(name)
|
|
53
|
+
@every = checked_every(every)
|
|
54
|
+
@at = checked_at(at)
|
|
55
|
+
@on = checked_on(on)
|
|
56
|
+
@retries = checked_retries(retries)
|
|
57
|
+
@notify = checked_flag('notify', notify)
|
|
58
|
+
@show_browser = checked_flag('show_browser', show_browser)
|
|
59
|
+
@selection = checked_selection(selection)
|
|
60
|
+
@os_id = checked_os_id(os_id)
|
|
61
|
+
@ruby = checked_path('ruby', ruby)
|
|
62
|
+
@bundle = checked_path('bundle', bundle)
|
|
63
|
+
@gem_env = checked_gem_env(gem_env)
|
|
64
|
+
@project_root = checked_path('project_root', project_root)
|
|
65
|
+
@created_at = checked_text('created_at', created_at)
|
|
66
|
+
freeze
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def to_h
|
|
70
|
+
{ 'name' => name, 'every' => every, 'at' => at, 'on' => on, 'retries' => retries, 'notify' => notify, 'show_browser' => show_browser,
|
|
71
|
+
'selection' => selection.to_h, 'os_id' => os_id, 'ruby' => ruby, 'bundle' => bundle, 'gem_env' => gem_env.dup, 'created_at' => created_at }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def checked_name(name)
|
|
77
|
+
return name.dup.freeze if name.is_a?(String) && KIND.valid_name?(name)
|
|
78
|
+
|
|
79
|
+
raise Error, "Invalid schedule name #{Schedules.printable(name.inspect)}: use 1 to 60 of a-z, 0-9 and _"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def checked_selection(selection)
|
|
83
|
+
return selection if selection.is_a?(Selection)
|
|
84
|
+
|
|
85
|
+
raise Error, 'Invalid selection: use a Selection'
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'store'
|
|
4
|
+
require_relative 'schedule'
|
|
5
|
+
|
|
6
|
+
module RubyRaider
|
|
7
|
+
module Schedules
|
|
8
|
+
# .raider/schedules/schedules.yml: the project's test schedules (Store with Schedule)
|
|
9
|
+
class ScheduleStore < Store
|
|
10
|
+
DIR = Schedule::KIND.dir
|
|
11
|
+
GITIGNORE_LINE = "#{DIR}/".freeze
|
|
12
|
+
|
|
13
|
+
def initialize(project)
|
|
14
|
+
super(project, Schedule)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Appends .raider/schedules/ to the project's .gitignore (made when missing) unless that exact line is
|
|
18
|
+
# there; every byte already in the file is kept. The yml holds machine paths. true when it added it.
|
|
19
|
+
# Read and written through one handle that is not a link, so a linked .gitignore is never written through.
|
|
20
|
+
def ignore_in_git
|
|
21
|
+
File.open(gitignore, File::RDWR | File::APPEND | File::CREAT | NOFOLLOW | NONBLOCK, 0o644, binmode: true) do |file|
|
|
22
|
+
content = gitignore_text(file)
|
|
23
|
+
return false if ignored?(content)
|
|
24
|
+
|
|
25
|
+
prefix = content.empty? || content.end_with?("\n") ? '' : "\n"
|
|
26
|
+
file.write("#{prefix}#{GITIGNORE_LINE}\n")
|
|
27
|
+
end
|
|
28
|
+
true
|
|
29
|
+
rescue Errno::EACCES, Errno::EROFS
|
|
30
|
+
# A .gitignore we may not write is fine when it already has the line
|
|
31
|
+
return false if ignored?(File.open(gitignore, File::RDONLY | NOFOLLOW | NONBLOCK, binmode: true) { |file| gitignore_text(file) })
|
|
32
|
+
|
|
33
|
+
raise
|
|
34
|
+
rescue Errno::ELOOP, Errno::EMLINK
|
|
35
|
+
raise Error, ".gitignore is a link: add #{GITIGNORE_LINE} to it yourself"
|
|
36
|
+
rescue Errno::EISDIR
|
|
37
|
+
raise Error, '.gitignore must be a file'
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def gitignore = project.path('.gitignore')
|
|
43
|
+
|
|
44
|
+
def gitignore_text(file)
|
|
45
|
+
raise Error, '.gitignore must be a file' unless file.stat.file?
|
|
46
|
+
|
|
47
|
+
file.read.to_s.b
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def ignored?(content) = content.each_line.any? { |line| line.chomp == GITIGNORE_LINE }
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'rbconfig'
|
|
5
|
+
require_relative 'adapter'
|
|
6
|
+
require_relative 'next_run'
|
|
7
|
+
require_relative 'os_enumerator'
|
|
8
|
+
require_relative 'project'
|
|
9
|
+
require_relative 'run_log'
|
|
10
|
+
require_relative 'schedule'
|
|
11
|
+
require_relative 'schedule_store'
|
|
12
|
+
require_relative 'scheduler_steps'
|
|
13
|
+
require_relative 'selection'
|
|
14
|
+
|
|
15
|
+
module RubyRaider
|
|
16
|
+
module Schedules
|
|
17
|
+
# `raider schedule add | remove | list`: keeps the OS scheduler entries and .raider/schedules/schedules.yml
|
|
18
|
+
# in step (4.1's order). The OS entry is installed first and the yml written after, so the yml never names
|
|
19
|
+
# an entry the OS scheduler refused; remove takes the OS entry first, so a refusal keeps the yml entry to
|
|
20
|
+
# retry with. The OS entry is always built fresh (this ruby, bundle and gem env), never from the yml.
|
|
21
|
+
# :reek:TooManyInstanceVariables { max_instance_variables: 7 } -- the project, its store, the adapter, the streams, clock and OS
|
|
22
|
+
# :reek:TooManyConstants { max_constants: 7 } -- the kind, two time formats, the row keys and the list wording
|
|
23
|
+
class Scheduler
|
|
24
|
+
include SchedulerSteps
|
|
25
|
+
|
|
26
|
+
KIND = Schedule::KIND
|
|
27
|
+
TIME = '%a %H:%M'
|
|
28
|
+
LIST_TIME = '%a %-d %b %H:%M'
|
|
29
|
+
LAST = %w[status finished_at trigger counts].freeze
|
|
30
|
+
EMPTY = 'No schedules yet: raider schedule add <name> --every day --at 08:00'
|
|
31
|
+
UNLISTED = 'missing from schedules.yml'
|
|
32
|
+
RULE_KEYS = %w[every at on retries notify show_browser selection next_run_at].freeze
|
|
33
|
+
|
|
34
|
+
# The OS scheduler for this machine, for test schedules
|
|
35
|
+
def self.adapter(runner: Os::CommandRunner.new, host_os: RbConfig::CONFIG['host_os'])
|
|
36
|
+
Schedules.adapter(kind: KIND, runner: runner, host_os: host_os)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# adapter: nil picks the OS scheduler once add has checked everything else, so a refused command never touches it
|
|
40
|
+
def initialize(project, adapter: nil, store: ScheduleStore.new(project), out: $stdout, err: $stderr, now: -> { Time.now },
|
|
41
|
+
host_os: RbConfig::CONFIG['host_os'])
|
|
42
|
+
@project = project
|
|
43
|
+
@adapter = adapter
|
|
44
|
+
@store = store
|
|
45
|
+
@out = out
|
|
46
|
+
@err = err
|
|
47
|
+
@now = now
|
|
48
|
+
@host_os = host_os
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Adds the schedule, or replaces the one with its name. Every check runs before a folder, the yml or
|
|
52
|
+
# the OS scheduler is touched.
|
|
53
|
+
def add(name, every:, files: [], tag: nil, at: nil, on: nil, retries: 0, notify: false, show_browser: false)
|
|
54
|
+
checked_name(name)
|
|
55
|
+
now = @now.call
|
|
56
|
+
selection = Selection.parse(files: files, tag: tag, root: @project.root, host_os: @host_os)
|
|
57
|
+
schedule = Schedule.build(project: @project, name: name, every: every, at: at, on: on, retries: retries, notify: notify,
|
|
58
|
+
show_browser: show_browser, selection: selection, now: now, host_os: @host_os)
|
|
59
|
+
@project.check_show_browser! if schedule.show_browser
|
|
60
|
+
@project.check_raider_version!
|
|
61
|
+
current = adapter
|
|
62
|
+
schedule_dirs(name)
|
|
63
|
+
@store.writable!
|
|
64
|
+
old = @store.find(name)
|
|
65
|
+
current.install(schedule)
|
|
66
|
+
save(schedule)
|
|
67
|
+
ignore_in_git
|
|
68
|
+
@out.puts("#{old ? 'Replaced' : 'Scheduled'} #{name}: #{schedule.rule_text}, next run #{NextRun.after(schedule, now).strftime(TIME)}")
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def remove(name)
|
|
72
|
+
checked_name(name)
|
|
73
|
+
label = adapter.label
|
|
74
|
+
was_there = adapter.remove(Schedule.os_id_for(@project.root, name))
|
|
75
|
+
removed = delete(name, label)
|
|
76
|
+
raise Error, "No schedule #{name}" unless removed || was_there
|
|
77
|
+
|
|
78
|
+
@out.puts(was_there ? "Removed #{name}" : "Removed #{name} (it was already gone from #{label})")
|
|
79
|
+
@err.puts(unreadable_warning(name, label)) if removed.is_a?(Store::Removed)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# notice: the 4.1 cleanup's line (Legacy) for the JSON; as text it is already on stderr
|
|
83
|
+
def list(json: false, notice: nil)
|
|
84
|
+
now = @now.call
|
|
85
|
+
listed = @store.all
|
|
86
|
+
rows = listed.map { |schedule| row(schedule, now) } + unlisted_rows(listed.map(&:name))
|
|
87
|
+
return @out.puts(JSON.generate({ 'notice' => notice, 'schedules' => rows.map { |entry| entry[:json] } })) if json
|
|
88
|
+
|
|
89
|
+
return @out.puts(EMPTY) if rows.empty?
|
|
90
|
+
|
|
91
|
+
rows.each { |entry| @out.puts(Schedules.printable(entry[:text])) }
|
|
92
|
+
note = missed_runs_note
|
|
93
|
+
@out.puts(note) if note
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
attr_reader :store
|
|
99
|
+
|
|
100
|
+
def adapter = @adapter ||= Scheduler.adapter(host_os: @host_os)
|
|
101
|
+
|
|
102
|
+
def checked_name(name)
|
|
103
|
+
return if name.is_a?(String) && KIND.valid_name?(name)
|
|
104
|
+
|
|
105
|
+
raise Error, "Invalid schedule name #{Schedules.printable(name.inspect)}: use 1 to 60 of a-z, 0-9 and _"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# .raider/schedules/ and .raider/schedules/<name>/ (scheduler.log): 0700, no part of either a link
|
|
109
|
+
def schedule_dirs(name)
|
|
110
|
+
RunLog.private_dir(@project, KIND.dir)
|
|
111
|
+
RunLog.private_dir(@project, KIND.dir, name)
|
|
112
|
+
rescue SystemCallError => e
|
|
113
|
+
raise Error, "Could not make #{KIND.dir}/#{name}: #{e.message}"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# The schedule is installed and saved by now, so a .gitignore that can't take the line only warns
|
|
117
|
+
def ignore_in_git
|
|
118
|
+
@store.ignore_in_git
|
|
119
|
+
rescue Error, SystemCallError => e
|
|
120
|
+
@err.puts("Could not add #{ScheduleStore::GITIGNORE_LINE} to .gitignore: #{Schedules.printable(e.message)}")
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def row(schedule, now)
|
|
124
|
+
name = schedule.name
|
|
125
|
+
next_run = NextRun.after(schedule, now)
|
|
126
|
+
state = os_state(schedule)
|
|
127
|
+
last = last_run(name)
|
|
128
|
+
json = { 'name' => name, 'every' => schedule.every, 'at' => schedule.at, 'on' => schedule.on, 'retries' => schedule.retries,
|
|
129
|
+
'notify' => schedule.notify, 'show_browser' => schedule.show_browser, 'selection' => schedule.selection.to_h,
|
|
130
|
+
'next_run_at' => next_run.iso8601, 'os_state' => state, 'last' => last }
|
|
131
|
+
{ json: json, text: line(name, rule(schedule), "next #{next_run.strftime(LIST_TIME)}", last, state) }
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# This project's OS entries that schedules.yml has no entry for, found by enumerating the OS scheduler
|
|
135
|
+
def unlisted_rows(listed)
|
|
136
|
+
(unlisted_names - listed).map do |name|
|
|
137
|
+
last = last_run(name)
|
|
138
|
+
json = { 'name' => name, **RULE_KEYS.to_h { |key| [key, nil] }, 'os_state' => UNLISTED, 'last' => last }
|
|
139
|
+
{ json: json, text: line(name, '-', '-', last, UNLISTED) }
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def unlisted_names
|
|
144
|
+
OsEnumerator.new(adapter, schedule_class: Schedule, root: @project.root).names
|
|
145
|
+
rescue Error
|
|
146
|
+
[]
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# The ruby first, then the spec files (no need to ask the OS about a schedule that can't run), then the OS
|
|
150
|
+
def os_state(schedule)
|
|
151
|
+
return RUBY_NOT_FOUND unless ruby_found?(schedule)
|
|
152
|
+
|
|
153
|
+
missing = schedule.selection.missing_file(@project.root)
|
|
154
|
+
return "test file not found: #{missing}" if missing
|
|
155
|
+
|
|
156
|
+
os_entry_state(schedule.os_id)
|
|
157
|
+
rescue Error => e
|
|
158
|
+
"unknown (#{Schedules.printable(e.message)})"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def rule(schedule)
|
|
162
|
+
selection = schedule.selection
|
|
163
|
+
which = case selection.kind
|
|
164
|
+
when 'tag' then "tag #{selection.tag}"
|
|
165
|
+
when 'files' then selection.files.size == 1 ? selection.files.first : "#{selection.files.size} files"
|
|
166
|
+
else 'all tests'
|
|
167
|
+
end
|
|
168
|
+
flags = [('notify' if schedule.notify), ('browser shown' if schedule.show_browser)].compact
|
|
169
|
+
"#{which}, #{schedule.rule_text}#{" (#{flags.join(', ')})" unless flags.empty?}"
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def last_run(name)
|
|
173
|
+
last = RunLog.new(@project, name, dir: KIND.dir).last_result
|
|
174
|
+
last && LAST.to_h { |key| [key, last[key]] }
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
|
|
5
|
+
module RubyRaider
|
|
6
|
+
module Schedules
|
|
7
|
+
# The steps every scheduler shares (4.1 shared them too): the yml written after the OS entry with a
|
|
8
|
+
# rollback, the yml entry removed after the OS one, and the parts of a list row that ask the OS.
|
|
9
|
+
# The includer gives `adapter` and `store`.
|
|
10
|
+
module SchedulerSteps
|
|
11
|
+
RUBY_NOT_FOUND = 'Ruby not found: re-schedule'
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
# Writes the yml; when that fails the OS entry just installed is removed, so neither names the schedule
|
|
16
|
+
def save(schedule)
|
|
17
|
+
store.save(schedule)
|
|
18
|
+
rescue Error, SystemCallError => e
|
|
19
|
+
label = adapter.label
|
|
20
|
+
begin
|
|
21
|
+
adapter.remove(schedule.os_id)
|
|
22
|
+
rescue Error => remove_error
|
|
23
|
+
raise Error, "#{e.message} (and removing the #{label} entry failed: #{remove_error.message})"
|
|
24
|
+
end
|
|
25
|
+
raise Error, "#{e.message}: the #{label} entry was removed too, so #{schedule.name} is not scheduled"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# The OS entry is gone by now: say so when the yml can't be written
|
|
29
|
+
def delete(name, label)
|
|
30
|
+
store.delete(name)
|
|
31
|
+
rescue Error, SystemCallError => e
|
|
32
|
+
raise Error, "#{e.message}: the #{label} entry was already removed, but schedules.yml still lists #{name}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def unreadable_warning(name, label)
|
|
36
|
+
"The schedules.yml entry for #{name} could not be read: a #{label} entry made from a different project path may remain"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The OS entry runs `<ruby> <bundle> ...`: ruby is executed, bundle is only read by it
|
|
40
|
+
def ruby_found?(schedule)
|
|
41
|
+
ruby = schedule.ruby
|
|
42
|
+
bundle = schedule.bundle
|
|
43
|
+
File.file?(ruby) && File.executable?(ruby) && File.file?(bundle) && File.readable?(bundle)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def os_entry_state(os_id) = adapter.present?(os_id) ? 'ok' : "missing from #{adapter.label}"
|
|
47
|
+
|
|
48
|
+
# An entry whose state can't be read is left out: only a listed schedule gets an unknown row
|
|
49
|
+
def present?(os_id)
|
|
50
|
+
adapter.present?(os_id)
|
|
51
|
+
rescue Error
|
|
52
|
+
false
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# nil also when there is no OS scheduler to ask (the rows already say unknown)
|
|
56
|
+
def missed_runs_note
|
|
57
|
+
current = adapter
|
|
58
|
+
current.respond_to?(:missed_runs_note) ? current.missed_runs_note : nil
|
|
59
|
+
rescue Error
|
|
60
|
+
nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def line(name, rule, next_run, last, state)
|
|
64
|
+
result = last ? "#{last['status']} #{last['finished_at']}" : 'never run'
|
|
65
|
+
"#{name.ljust(30)} #{rule} #{next_run} #{result} #{state}"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rbconfig'
|
|
4
|
+
require_relative 'base'
|
|
5
|
+
require_relative 'os/task_scheduler'
|
|
6
|
+
|
|
7
|
+
module RubyRaider
|
|
8
|
+
module Schedules
|
|
9
|
+
# Which tests a schedule runs: all of them, one tag, or listed spec files. Immutable.
|
|
10
|
+
# Checking is in two steps: the shape and every path's syntax (new, from_h: a schedules.yml entry still
|
|
11
|
+
# loads when a file was deleted), and whether each file is there (parse for add, check! for run). A file
|
|
12
|
+
# that is there but resolves outside the project is refused by both.
|
|
13
|
+
# :reek:MissingSafeMethod { exclude: [ check! ] } -- a check that raises; missing_file is its safe twin
|
|
14
|
+
# :reek:TooManyConstants { max_constants: 7 } -- the kinds, the tag and file rules, the cap, Windows
|
|
15
|
+
class Selection
|
|
16
|
+
KINDS = %w[all tag files].freeze
|
|
17
|
+
KEYS = %w[kind tag files].freeze
|
|
18
|
+
TAG = /\A[A-Za-z0-9_:-]+\z/
|
|
19
|
+
MAX_FILES = 200
|
|
20
|
+
WINDOWS = /mswin|mingw|cygwin/
|
|
21
|
+
FILE_RULE = 'use a path inside the project ending in _spec.rb, with no .. and no leading - or /'
|
|
22
|
+
WINDOWS_RULE = 'Task Scheduler and ruby.exe would expand %...%, *, ?, [ or { in it on Windows: rename the file or use a tag'
|
|
23
|
+
|
|
24
|
+
attr_reader :kind, :tag, :files
|
|
25
|
+
|
|
26
|
+
def self.all = new(kind: 'all')
|
|
27
|
+
|
|
28
|
+
# What `raider schedule add` was given: no files and no tag is all tests; every file must be there
|
|
29
|
+
def self.parse(files:, tag:, root:, host_os: RbConfig::CONFIG['host_os'])
|
|
30
|
+
raise Error, 'Invalid spec files: use a list of paths' unless files.is_a?(Array)
|
|
31
|
+
raise Error, 'Use --tag or spec files, not both' if !tag.nil? && !files.empty?
|
|
32
|
+
|
|
33
|
+
selection = if !tag.nil? then new(kind: 'tag', tag: tag)
|
|
34
|
+
elsif files.empty? then all
|
|
35
|
+
else new(kind: 'files', files: files, host_os: host_os)
|
|
36
|
+
end
|
|
37
|
+
selection.check!(root)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The selection of a schedules.yml entry; a missing file still loads (missing_file names it)
|
|
41
|
+
def self.from_h(hash, root:, host_os: RbConfig::CONFIG['host_os'])
|
|
42
|
+
raise Error, 'Invalid selection: use kind all, tag or files' unless hash.is_a?(Hash) && (hash.keys - KEYS).empty?
|
|
43
|
+
|
|
44
|
+
selection = new(kind: hash['kind'], tag: hash['tag'], files: hash['files'], host_os: host_os)
|
|
45
|
+
selection.missing_file(root)
|
|
46
|
+
selection
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def initialize(kind:, tag: nil, files: nil, host_os: RbConfig::CONFIG['host_os'])
|
|
50
|
+
raise Error, "Invalid selection kind #{kind.inspect}: use all, tag or files" unless KINDS.include?(kind)
|
|
51
|
+
|
|
52
|
+
@kind = kind.dup.freeze
|
|
53
|
+
@tag = kind == 'tag' ? checked_tag(tag) : nothing('tag', tag)
|
|
54
|
+
@files = kind == 'files' ? checked_files(files, host_os.match?(WINDOWS)) : nothing('files', files) || [].freeze
|
|
55
|
+
freeze
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The first listed file that is not in the project (nil when all are there); raises when one resolves
|
|
59
|
+
# outside the project. root is the project's real path.
|
|
60
|
+
def missing_file(root) = files.reject { |file| present?(root, file) }.first
|
|
61
|
+
|
|
62
|
+
# Itself when every file is there, otherwise raises
|
|
63
|
+
def check!(root)
|
|
64
|
+
missing = missing_file(root)
|
|
65
|
+
raise Error, "#{missing} not found" if missing
|
|
66
|
+
|
|
67
|
+
self
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def to_h
|
|
71
|
+
case kind
|
|
72
|
+
when 'tag' then { 'kind' => kind, 'tag' => tag }
|
|
73
|
+
when 'files' then { 'kind' => kind, 'files' => files.dup }
|
|
74
|
+
else { 'kind' => kind }
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def nothing(label, value)
|
|
81
|
+
raise Error, "Invalid selection: #{label} is only for kind #{label == 'tag' ? 'tag' : 'files'}" unless value.nil?
|
|
82
|
+
|
|
83
|
+
nil
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def checked_tag(tag)
|
|
87
|
+
return tag.dup.freeze if tag.is_a?(String) && tag.valid_encoding? && TAG.match?(tag)
|
|
88
|
+
|
|
89
|
+
raise Error, "Invalid tag #{Schedules.printable(tag.inspect)}: use letters, digits, _, : and -"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Duplicates removed, order kept, then counted
|
|
93
|
+
def checked_files(files, windows)
|
|
94
|
+
raise Error, 'Invalid spec files: use a list of one or more paths' unless files.is_a?(Array) && !files.empty?
|
|
95
|
+
|
|
96
|
+
unique = files.map { |file| checked_file(file, windows) }.uniq
|
|
97
|
+
raise Error, "Too many spec files (#{unique.size}): select at most #{MAX_FILES}, or use a tag" if unique.size > MAX_FILES
|
|
98
|
+
|
|
99
|
+
unique.freeze
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def checked_file(file, windows)
|
|
103
|
+
raise Error, "Invalid spec file #{Schedules.printable(file.inspect)}: #{FILE_RULE}" unless spec_path?(file, windows)
|
|
104
|
+
raise Error, "Invalid spec file #{file.inspect}: #{WINDOWS_RULE}" if windows && file.match?(Regexp.union(Os::TaskScheduler::EXPANDS, Os::TaskScheduler::GLOB))
|
|
105
|
+
|
|
106
|
+
file.dup.freeze
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def spec_path?(file, windows)
|
|
110
|
+
file.is_a?(String) && file.valid_encoding? && !file.match?(/[[:cntrl:]]/) && file.end_with?('_spec.rb') && relative?(file, windows)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def relative?(file, windows)
|
|
114
|
+
return false if file.start_with?('-', '/') || File.absolute_path?(file)
|
|
115
|
+
return false if windows && file.match?(/\A(?:\\|[A-Za-z]:)/)
|
|
116
|
+
|
|
117
|
+
!file.split(windows ? %r{[/\\]} : '/').include?('..')
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# true when the file is in the project, false when it is not there; raises when it resolves outside
|
|
121
|
+
def present?(root, file)
|
|
122
|
+
real = File.realpath(File.join(root, file))
|
|
123
|
+
raise Error, "#{file} points outside the project" unless real.start_with?("#{root}#{File::SEPARATOR}")
|
|
124
|
+
|
|
125
|
+
File.file?(real)
|
|
126
|
+
rescue Errno::ENOENT, Errno::ENOTDIR
|
|
127
|
+
false
|
|
128
|
+
rescue SystemCallError => e
|
|
129
|
+
raise Error, "#{file} can't be read: #{e.message}"
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'English'
|
|
4
|
+
|
|
5
|
+
module RubyRaider
|
|
6
|
+
module Schedules
|
|
7
|
+
# Runs one rspec process with an argv array and no shell, under the project's own bundle rather
|
|
8
|
+
# than the one raider runs in, and stops it the way a person would: with exactly one INT.
|
|
9
|
+
#
|
|
10
|
+
# rspec stops gracefully on its first INT (it finishes the example, prints the summary and runs at_exit
|
|
11
|
+
# hooks, where raider_reporter writes run.json) and force-quits on a second one. The child stays in the
|
|
12
|
+
# terminal's process group (it can read the terminal, and a closed terminal ends it), so a Ctrl-C typed
|
|
13
|
+
# there already reached it. Hence:
|
|
14
|
+
# - TERM is always passed on as one INT;
|
|
15
|
+
# - INT is passed on only with forward_int (true when the INT can't have come from the terminal, e.g.
|
|
16
|
+
# stdin is not a TTY); otherwise the child is taken to have had it;
|
|
17
|
+
# - a signal before the child exists is passed on once it does;
|
|
18
|
+
# - never more than one INT per run. On Windows the console already sent Ctrl-C to the child.
|
|
19
|
+
module Spawn
|
|
20
|
+
# [exit status (nil when it could not start or died of a signal), true when INT or TERM stopped it]
|
|
21
|
+
def self.call(env, argv, chdir, forward_int: true)
|
|
22
|
+
state = { pid: nil, stopped: false, interrupted: false }
|
|
23
|
+
code = run(env, argv, chdir, state, forward_int)
|
|
24
|
+
[code, state[:stopped] == true]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# The exit status, with the traps set from before the spawn (so a stop in between is not lost) to the end
|
|
28
|
+
def self.run(env, argv, chdir, state, forward_int)
|
|
29
|
+
previous = %w[INT TERM].to_h { |signal| [signal, trap(signal) { stop(state, signal, forward_int) }] }
|
|
30
|
+
wait = lambda do
|
|
31
|
+
pid = state[:pid] = Process.spawn(env, *argv, chdir: chdir)
|
|
32
|
+
interrupt(state) if state[:stopped]
|
|
33
|
+
Process.wait(pid)
|
|
34
|
+
# Reaped: its pid may be reused, so a later signal must not reach it
|
|
35
|
+
state[:pid] = nil
|
|
36
|
+
$CHILD_STATUS.exitstatus
|
|
37
|
+
end
|
|
38
|
+
defined?(Bundler) ? Bundler.with_unbundled_env(&wait) : wait.call
|
|
39
|
+
rescue SystemCallError => e
|
|
40
|
+
warn("Could not start #{argv.first}: #{e.message}")
|
|
41
|
+
nil
|
|
42
|
+
ensure
|
|
43
|
+
previous&.each { |signal, handler| trap(signal, handler || 'DEFAULT') }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Called from the trap: marks the run stopped and passes the signal on as INT when the child needs it
|
|
47
|
+
def self.stop(state, signal, forward_int)
|
|
48
|
+
state[:stopped] = true
|
|
49
|
+
return unless state[:pid] # passed on right after the spawn
|
|
50
|
+
|
|
51
|
+
# A terminal Ctrl-C reached the child too: count it as the child's one INT
|
|
52
|
+
return state[:interrupted] = true if signal == 'INT' && !forward_int
|
|
53
|
+
|
|
54
|
+
interrupt(state)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# INT, at most once per run
|
|
58
|
+
def self.interrupt(state)
|
|
59
|
+
return if state[:interrupted] || Gem.win_platform?
|
|
60
|
+
|
|
61
|
+
state[:interrupted] = true
|
|
62
|
+
Process.kill('INT', state[:pid])
|
|
63
|
+
rescue SystemCallError
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -3,15 +3,15 @@
|
|
|
3
3
|
require 'fileutils'
|
|
4
4
|
require 'securerandom'
|
|
5
5
|
require 'yaml'
|
|
6
|
-
require_relative '
|
|
6
|
+
require_relative 'base'
|
|
7
7
|
|
|
8
8
|
module RubyRaider
|
|
9
|
-
module
|
|
10
|
-
#
|
|
9
|
+
module Schedules
|
|
10
|
+
# <kind dir>/schedules.yml: a project's schedules of one kind, sorted by name. The OS entries are made
|
|
11
11
|
# from it; it is written whole through a temp file and a rename, so a reader never sees half a file.
|
|
12
|
+
# A subclass passes the schedule class (a Rule with from_h, to_h and os_id_for); its Kind names the folder.
|
|
12
13
|
# :reek:MissingSafeMethod { exclude: [ writable! ] } -- a check that raises, with no safe twin to offer
|
|
13
|
-
class
|
|
14
|
-
DIR = '.raider/flows'
|
|
14
|
+
class Store
|
|
15
15
|
FILE = 'schedules.yml'
|
|
16
16
|
NOFOLLOW = defined?(File::NOFOLLOW) ? File::NOFOLLOW : 0
|
|
17
17
|
NONBLOCK = defined?(File::NONBLOCK) ? File::NONBLOCK : 0
|
|
@@ -27,9 +27,11 @@ module RubyRaider
|
|
|
27
27
|
end
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
-
def initialize(project)
|
|
30
|
+
def initialize(project, schedule_class)
|
|
31
31
|
@project = project
|
|
32
|
-
@
|
|
32
|
+
@schedule_class = schedule_class
|
|
33
|
+
@dir = schedule_class.kind.dir
|
|
34
|
+
@file = project.path("#{@dir}/#{FILE}")
|
|
33
35
|
end
|
|
34
36
|
|
|
35
37
|
# Every valid entry, sorted by name; a broken entry or file is skipped
|
|
@@ -62,13 +64,15 @@ module RubyRaider
|
|
|
62
64
|
return nil if named.empty?
|
|
63
65
|
|
|
64
66
|
write(kept)
|
|
65
|
-
named.filter_map { |entry| from_entry(entry) }.first || Removed.new(name,
|
|
67
|
+
named.filter_map { |entry| from_entry(entry) }.first || Removed.new(name, @schedule_class.os_id_for(@project.root, name))
|
|
66
68
|
end
|
|
67
69
|
|
|
68
70
|
private
|
|
69
71
|
|
|
72
|
+
attr_reader :project
|
|
73
|
+
|
|
70
74
|
def from_entry(entry)
|
|
71
|
-
|
|
75
|
+
@schedule_class.from_h(entry, @project.root)
|
|
72
76
|
rescue Error
|
|
73
77
|
nil
|
|
74
78
|
end
|
|
@@ -92,7 +96,7 @@ module RubyRaider
|
|
|
92
96
|
|
|
93
97
|
# The entries to rewrite, keeping ones this version can't read; refuses to overwrite a broken file
|
|
94
98
|
def writable_entries
|
|
95
|
-
read_entries || raise(Error, "#{
|
|
99
|
+
read_entries || raise(Error, "#{@dir}/#{FILE} is not a schedules file: fix or remove it, then try again")
|
|
96
100
|
end
|
|
97
101
|
|
|
98
102
|
# Read through one handle that is not a link; nil when there is no file, false when it is a link,
|
|
@@ -121,11 +125,11 @@ module RubyRaider
|
|
|
121
125
|
FileUtils.rm_f(temp) if temp && File.exist?(temp)
|
|
122
126
|
end
|
|
123
127
|
|
|
124
|
-
# A 0700 folder inside the project that is not a link
|
|
128
|
+
# A 0700 folder inside the project that is not a link
|
|
125
129
|
def private_dir
|
|
126
|
-
dir = @project.path(
|
|
130
|
+
dir = @project.path(@dir)
|
|
127
131
|
FileUtils.mkdir_p(dir)
|
|
128
|
-
raise Error, "#{
|
|
132
|
+
raise Error, "#{@dir} must be a folder, not a link" unless File.lstat(dir).directory?
|
|
129
133
|
|
|
130
134
|
File.chmod(0o700, dir)
|
|
131
135
|
dir
|
data/lib/version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
4.
|
|
1
|
+
4.2.0
|