dr_talon 0.1.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 +7 -0
- data/CHANGELOG.md +60 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +270 -0
- data/Rakefile +10 -0
- data/exe/talon +6 -0
- data/lib/dr_talon/cli/engine.rb +274 -0
- data/lib/dr_talon/cli/generate.rb +29 -0
- data/lib/dr_talon/cli.rb +343 -0
- data/lib/dr_talon/config.rb +29 -0
- data/lib/dr_talon/credentials.rb +94 -0
- data/lib/dr_talon/deps/dependency.rb +238 -0
- data/lib/dr_talon/deps/git.rb +69 -0
- data/lib/dr_talon/deps/installer.rb +207 -0
- data/lib/dr_talon/deps/manifest.rb +220 -0
- data/lib/dr_talon/deps/tree.rb +97 -0
- data/lib/dr_talon/deps.rb +62 -0
- data/lib/dr_talon/doctor.rb +161 -0
- data/lib/dr_talon/engine/fetchers.rb +251 -0
- data/lib/dr_talon/engine/git_ignore.rb +63 -0
- data/lib/dr_talon/engine/importer.rb +103 -0
- data/lib/dr_talon/engine/inspector.rb +113 -0
- data/lib/dr_talon/engine/installation.rb +77 -0
- data/lib/dr_talon/engine/materializer.rb +172 -0
- data/lib/dr_talon/engine/spec.rb +38 -0
- data/lib/dr_talon/engine/store.rb +119 -0
- data/lib/dr_talon/engine.rb +50 -0
- data/lib/dr_talon/errors.rb +5 -0
- data/lib/dr_talon/file_copy.rb +49 -0
- data/lib/dr_talon/generators/new_project.rb +289 -0
- data/lib/dr_talon/generators/talon_code.rb +92 -0
- data/lib/dr_talon/http.rb +120 -0
- data/lib/dr_talon/paths.rb +115 -0
- data/lib/dr_talon/project.rb +101 -0
- data/lib/dr_talon/runner.rb +188 -0
- data/lib/dr_talon/talon_framework.rb +103 -0
- data/lib/dr_talon/templates/bare/main_tests.rb.tt +7 -0
- data/lib/dr_talon/templates/bare/rubocop.yml.tt +8 -0
- data/lib/dr_talon/templates/bare/solargraph.yml.tt +24 -0
- data/lib/dr_talon/templates/lib/app/main.rb.tt +13 -0
- data/lib/dr_talon/templates/lib/lib/library.rb.tt +4 -0
- data/lib/dr_talon/templates/lib/lib/version.rb.tt +3 -0
- data/lib/dr_talon/templates/lib/rubocop.yml.tt +9 -0
- data/lib/dr_talon/templates/lib/solargraph.yml.tt +23 -0
- data/lib/dr_talon/templates/lib/test/test_helper.rb.tt +1 -0
- data/lib/dr_talon/templates/lib/test/tests.rb.tt +8 -0
- data/lib/dr_talon/templates/shared/Gemfile.tt +10 -0
- data/lib/dr_talon/templates/shared/README.md.tt +45 -0
- data/lib/dr_talon/templates/shared/editorconfig.tt +9 -0
- data/lib/dr_talon/templates/shared/gitignore.tt +19 -0
- data/lib/dr_talon/templates/talon/game/dependencies.rb.tt +3 -0
- data/lib/dr_talon/templates/talon/game/main.rb.tt +9 -0
- data/lib/dr_talon/templates/talon/game/spin.rb.tt +7 -0
- data/lib/dr_talon/templates/talon/game/title.rb.tt +8 -0
- data/lib/dr_talon/templates/talon/game/title_tests.rb.tt +16 -0
- data/lib/dr_talon/templates/talon/generate/component.rb.tt +19 -0
- data/lib/dr_talon/templates/talon/generate/component_tests.rb.tt +15 -0
- data/lib/dr_talon/templates/talon/generate/prefab.rb.tt +11 -0
- data/lib/dr_talon/templates/talon/generate/prefab_tests.rb.tt +15 -0
- data/lib/dr_talon/templates/talon/generate/scene.rb.tt +14 -0
- data/lib/dr_talon/templates/talon/generate/scene_tests.rb.tt +13 -0
- data/lib/dr_talon/test_report.rb +76 -0
- data/lib/dr_talon/version.rb +5 -0
- data/lib/dr_talon.rb +17 -0
- data/sig/dr_talon.rbs +524 -0
- metadata +142 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
module DrTalon
|
|
7
|
+
module Engine
|
|
8
|
+
# Copies a cached engine into a {Project} and records what it placed in a
|
|
9
|
+
# manifest, so switching engines later removes exactly those files and
|
|
10
|
+
# never touches the game or anything else in the project.
|
|
11
|
+
class Materializer
|
|
12
|
+
MANIFEST = File.join(".talon", "engine.yml")
|
|
13
|
+
|
|
14
|
+
# Top-level files a +:game_only+ project gets: enough to run the game
|
|
15
|
+
# and its tests. Windows builds may also need the DLLs next to the exe.
|
|
16
|
+
GAME_ONLY_FILES = %w[dragonruby dragonruby.exe font.ttf].freeze
|
|
17
|
+
|
|
18
|
+
# Files talon never copies into a project.
|
|
19
|
+
SKIPPED = [Installation::METADATA_FILE].freeze
|
|
20
|
+
|
|
21
|
+
# File names never copied, wherever they are (DragonRuby's zips include
|
|
22
|
+
# macOS Finder metadata).
|
|
23
|
+
SKIPPED_NAMES = %w[.DS_Store].freeze
|
|
24
|
+
|
|
25
|
+
# Project files that differ from the engine's and aren't from a previous
|
|
26
|
+
# install, so overwriting them could lose someone's work.
|
|
27
|
+
class Conflict < Error; end
|
|
28
|
+
|
|
29
|
+
# @!attribute [r] engine
|
|
30
|
+
# @return [String, nil] the installed {Installation#id}
|
|
31
|
+
# @!attribute [r] files
|
|
32
|
+
# @return [Array<String>] project-relative paths talon placed
|
|
33
|
+
Manifest = Data.define(:engine, :files)
|
|
34
|
+
|
|
35
|
+
# @!attribute [r] copied
|
|
36
|
+
# @return [Integer] files written
|
|
37
|
+
# @!attribute [r] removed
|
|
38
|
+
# @return [Integer] files from the previous engine that were deleted
|
|
39
|
+
# @!attribute [r] cloned
|
|
40
|
+
# @return [Integer] how many of the copies were reflinks
|
|
41
|
+
Result = Data.define(:copied, :removed, :cloned)
|
|
42
|
+
|
|
43
|
+
# @param project [Project]
|
|
44
|
+
def initialize(project)
|
|
45
|
+
@project = project
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @return [Manifest, nil]
|
|
49
|
+
def manifest
|
|
50
|
+
path = @project.path(MANIFEST)
|
|
51
|
+
return nil unless File.file?(path)
|
|
52
|
+
|
|
53
|
+
data = YAML.safe_load_file(path)
|
|
54
|
+
raise Error, "#{MANIFEST} is not a mapping" unless data.is_a?(Hash)
|
|
55
|
+
|
|
56
|
+
files = Array(data["files"]).map(&:to_s)
|
|
57
|
+
# Entries are deleted on the next install, so never trust one that
|
|
58
|
+
# points outside the project.
|
|
59
|
+
unsafe = files.find { |relative| relative.start_with?("/") || relative.split(%r{[/\\]}).include?("..") }
|
|
60
|
+
raise Error, "#{MANIFEST} lists a path outside the project: #{unsafe}" if unsafe
|
|
61
|
+
|
|
62
|
+
Manifest.new(engine: data["engine"], files: files)
|
|
63
|
+
rescue Psych::SyntaxError => e
|
|
64
|
+
raise Error, "Can't read #{MANIFEST}: #{e.message}"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# The engine files a project of this layout gets, relative to both the
|
|
68
|
+
# engine root and the project root.
|
|
69
|
+
#
|
|
70
|
+
# @param installation [Installation]
|
|
71
|
+
# @return [Array<String>]
|
|
72
|
+
def files_for(installation)
|
|
73
|
+
all = Dir.glob("**/*", File::FNM_DOTMATCH, base: installation.path).select do |relative|
|
|
74
|
+
path = File.join(installation.path, relative)
|
|
75
|
+
(File.file?(path) || File.symlink?(path)) && !File.basename(relative).match?(/\A\.\.?\z/)
|
|
76
|
+
end
|
|
77
|
+
all.reject! do |relative|
|
|
78
|
+
relative.start_with?("mygame/") || SKIPPED.include?(relative) || SKIPPED_NAMES.include?(File.basename(relative))
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
if @project.layout == :game_only
|
|
82
|
+
all.select! { |relative| GAME_ONLY_FILES.include?(relative) || relative.match?(%r{\A[^/]+\.dll\z}i) }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
all.sort
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Project paths that would be overwritten but weren't placed by talon
|
|
89
|
+
# and differ from the engine's copy. Identical files are adopted
|
|
90
|
+
# silently, which covers projects started from an unzipped engine.
|
|
91
|
+
#
|
|
92
|
+
# @param installation [Installation]
|
|
93
|
+
# @return [Array<String>]
|
|
94
|
+
def conflicts(installation)
|
|
95
|
+
previous = manifest&.files || []
|
|
96
|
+
|
|
97
|
+
(files_for(installation) - previous).select do |relative|
|
|
98
|
+
target = @project.path(relative)
|
|
99
|
+
next false unless File.exist?(target) || File.symlink?(target)
|
|
100
|
+
|
|
101
|
+
source = File.join(installation.path, relative)
|
|
102
|
+
File.symlink?(target) || !File.file?(target) || !FileUtils.compare_file(source, target)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Removes the previous engine's files and copies in +installation+.
|
|
107
|
+
#
|
|
108
|
+
# @param installation [Installation]
|
|
109
|
+
# @param force [Boolean] overwrite {#conflicts}
|
|
110
|
+
# @return [Result]
|
|
111
|
+
# @raise [Conflict]
|
|
112
|
+
def install(installation, force: false)
|
|
113
|
+
conflicting = conflicts(installation)
|
|
114
|
+
if conflicting.any? && !force
|
|
115
|
+
listed = conflicting.first(10).map { |relative| " #{relative}" }.join("\n")
|
|
116
|
+
more = (conflicting.size > 10) ? "\n ...and #{conflicting.size - 10} more" : ""
|
|
117
|
+
raise Conflict, "These project files differ from #{installation.id}'s and weren't installed by talon:\n" \
|
|
118
|
+
"#{listed}#{more}\nMove them aside, or pass --force to overwrite them."
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
files = files_for(installation)
|
|
122
|
+
removed = remove_files(manifest&.files || [])
|
|
123
|
+
|
|
124
|
+
# Record the new files before copying, so a failed copy can be cleaned
|
|
125
|
+
# up by the next install rather than reported as conflicts.
|
|
126
|
+
write_manifest(Manifest.new(engine: installation.id, files: files))
|
|
127
|
+
|
|
128
|
+
cloned = files.count do |relative|
|
|
129
|
+
target = @project.path(relative)
|
|
130
|
+
FileUtils.mkdir_p(File.dirname(target))
|
|
131
|
+
FileCopy.copy(File.join(installation.path, relative), target)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
Result.new(copied: files.size, removed: removed, cloned: cloned)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
private
|
|
138
|
+
|
|
139
|
+
def remove_files(files)
|
|
140
|
+
removed = files.count do |relative|
|
|
141
|
+
target = @project.path(relative)
|
|
142
|
+
next false unless File.exist?(target) || File.symlink?(target)
|
|
143
|
+
|
|
144
|
+
File.unlink(target)
|
|
145
|
+
true
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
prune_empty_dirs(files)
|
|
149
|
+
removed
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Deletes directories emptied by {#remove_files}, deepest first, without
|
|
153
|
+
# ever going above the project root.
|
|
154
|
+
def prune_empty_dirs(files)
|
|
155
|
+
dirs = files.flat_map do |relative|
|
|
156
|
+
parts = File.dirname(relative).split("/")
|
|
157
|
+
parts.each_index.map { |i| parts[0..i].join("/") }
|
|
158
|
+
end
|
|
159
|
+
dirs.uniq.reject { |dir| dir == "." }.sort_by { |dir| -dir.count("/") }.each do |dir|
|
|
160
|
+
path = @project.path(dir)
|
|
161
|
+
Dir.rmdir(path) if File.directory?(path) && Dir.empty?(path)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def write_manifest(manifest)
|
|
166
|
+
path = @project.path(MANIFEST)
|
|
167
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
168
|
+
File.write(path, YAML.dump({"engine" => manifest.engine, "files" => manifest.files}))
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DrTalon
|
|
4
|
+
module Engine
|
|
5
|
+
Spec = Data.define(:version, :tier, :platform)
|
|
6
|
+
|
|
7
|
+
# A possibly partial engine identifier given on the command line:
|
|
8
|
+
# "7.23", "7.23-pro" or "7.23-pro-linux-amd64".
|
|
9
|
+
class Spec
|
|
10
|
+
PATTERN = /\A(?<version>\d+(?:\.\d+)+)(?:-(?<tier>#{TIERS.join("|")})(?:-(?<platform>[a-z0-9-]+))?)?\z/
|
|
11
|
+
|
|
12
|
+
# @param string [String]
|
|
13
|
+
# @return [Spec]
|
|
14
|
+
# @raise [Error] if +string+ isn't a valid spec
|
|
15
|
+
def self.parse(string)
|
|
16
|
+
match = PATTERN.match(string.to_s.strip)
|
|
17
|
+
unless match
|
|
18
|
+
raise Error, "Invalid engine \"#{string}\". Expected VERSION[-TIER[-PLATFORM]], e.g. 7.23 or 7.23-pro."
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
new(version: match[:version], tier: match[:tier], platform: match[:platform])
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @param installation [Installation]
|
|
25
|
+
# @return [Boolean] whether every part this spec names matches
|
|
26
|
+
def match?(installation)
|
|
27
|
+
installation.version == version &&
|
|
28
|
+
(tier.nil? || installation.tier == tier) &&
|
|
29
|
+
(platform.nil? || installation.platform == platform)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @return [String]
|
|
33
|
+
def to_s
|
|
34
|
+
[version, tier, platform].compact.join("-")
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
|
|
6
|
+
module DrTalon
|
|
7
|
+
module Engine
|
|
8
|
+
# The global cache of unpacked engines, one directory per {Installation#id}.
|
|
9
|
+
#
|
|
10
|
+
# DragonRuby only offers its latest release for download, so this cache is
|
|
11
|
+
# also the only archive of older versions.
|
|
12
|
+
class Store
|
|
13
|
+
# @return [String]
|
|
14
|
+
attr_reader :root
|
|
15
|
+
|
|
16
|
+
# @param root [String] usually {Paths#engines_dir}
|
|
17
|
+
# @param host_platform [String] preferred when a spec omits the platform
|
|
18
|
+
def initialize(root, host_platform: Engine.host_platform)
|
|
19
|
+
@root = root
|
|
20
|
+
@host_platform = host_platform
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @return [Array<Installation>] newest version first, then the most
|
|
24
|
+
# capable tier (see {TIERS})
|
|
25
|
+
def installations
|
|
26
|
+
return [] unless File.directory?(root)
|
|
27
|
+
|
|
28
|
+
Dir.children(root)
|
|
29
|
+
.reject { |name| name.start_with?(".") }
|
|
30
|
+
.map { |name| File.join(root, name) }
|
|
31
|
+
.select { |path| File.file?(File.join(path, Installation::METADATA_FILE)) }
|
|
32
|
+
.map { |path| Installation.load(path) }
|
|
33
|
+
.sort { |a, b| sort_key(b) <=> sort_key(a) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @param id [String] a full {Installation#id}
|
|
37
|
+
# @return [Installation, nil]
|
|
38
|
+
def get(id)
|
|
39
|
+
path = path_for(id)
|
|
40
|
+
Installation.load(path) if File.file?(File.join(path, Installation::METADATA_FILE))
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Resolves a possibly partial spec to one installation. When the spec
|
|
44
|
+
# omits the platform, engines for the host platform win.
|
|
45
|
+
#
|
|
46
|
+
# @param spec [String, Spec]
|
|
47
|
+
# @return [Installation]
|
|
48
|
+
# @raise [NotFound, Ambiguous]
|
|
49
|
+
def find(spec)
|
|
50
|
+
spec = Spec.parse(spec) unless spec.is_a?(Spec)
|
|
51
|
+
matches = installations.select { |installation| spec.match?(installation) }
|
|
52
|
+
raise NotFound, "No cached engine matches #{spec}. Run `talon engine list` to see what's installed." if matches.empty?
|
|
53
|
+
|
|
54
|
+
if matches.size > 1 && spec.platform.nil?
|
|
55
|
+
native = matches.select { |installation| installation.platform == @host_platform }
|
|
56
|
+
matches = native unless native.empty?
|
|
57
|
+
end
|
|
58
|
+
return matches.first if matches.one?
|
|
59
|
+
|
|
60
|
+
raise Ambiguous, "#{spec} matches more than one engine: #{matches.map(&:id).join(", ")}. Use a full id."
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Resolves +spec+ to an engine built for this machine, since a project
|
|
64
|
+
# can only run its host's build.
|
|
65
|
+
#
|
|
66
|
+
# @param spec [Spec]
|
|
67
|
+
# @param default_tier [String, nil] used when +spec+ omits the tier
|
|
68
|
+
# @return [Installation]
|
|
69
|
+
# @raise [Error] if +spec+ names another platform
|
|
70
|
+
# @raise [NotFound, Ambiguous]
|
|
71
|
+
def find_native(spec, default_tier: nil)
|
|
72
|
+
if spec.platform && spec.platform != @host_platform
|
|
73
|
+
raise Error, "This machine runs #{@host_platform} engines; #{spec} is for #{spec.platform}."
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
find(Spec.new(version: spec.version, tier: spec.tier || default_tier, platform: @host_platform))
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# @return [Installation, nil] the newest engine for this machine
|
|
80
|
+
def latest_native
|
|
81
|
+
installations.find { |installation| installation.platform == @host_platform }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# @param id [String]
|
|
85
|
+
# @return [String]
|
|
86
|
+
def path_for(id)
|
|
87
|
+
File.join(root, id)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Larger sorts first: newer versions, then more capable tiers.
|
|
91
|
+
def sort_key(installation)
|
|
92
|
+
[installation.gem_version, -(TIERS.index(installation.tier) || TIERS.size), installation.platform]
|
|
93
|
+
end
|
|
94
|
+
private :sort_key
|
|
95
|
+
|
|
96
|
+
# Deletes an installation from the cache.
|
|
97
|
+
#
|
|
98
|
+
# @param installation [Installation]
|
|
99
|
+
# @return [void]
|
|
100
|
+
def remove(installation)
|
|
101
|
+
FileUtils.rm_rf(installation.path)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Yields an empty directory inside the store, on the same filesystem, so
|
|
105
|
+
# a finished import can be moved into place with a rename. The directory
|
|
106
|
+
# is removed afterwards.
|
|
107
|
+
#
|
|
108
|
+
# @yieldparam dir [String]
|
|
109
|
+
# @return [Object] the block's result
|
|
110
|
+
def stage
|
|
111
|
+
FileUtils.mkdir_p(root)
|
|
112
|
+
dir = Dir.mktmpdir(".staging-", root)
|
|
113
|
+
yield dir
|
|
114
|
+
ensure
|
|
115
|
+
FileUtils.rm_rf(dir) if dir
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rbconfig"
|
|
4
|
+
|
|
5
|
+
module DrTalon
|
|
6
|
+
# Cached DragonRuby engines: identifying, importing and looking them up.
|
|
7
|
+
module Engine
|
|
8
|
+
# License tiers, most capable first. Jam is DragonRuby's free license:
|
|
9
|
+
# Standard's API, but it only exports to the web.
|
|
10
|
+
TIERS = %w[pro indie standard jam].freeze
|
|
11
|
+
|
|
12
|
+
# The engine path doesn't look like a DragonRuby distribution, or a detail
|
|
13
|
+
# such as its version can't be determined.
|
|
14
|
+
class InvalidEngine < Error; end
|
|
15
|
+
|
|
16
|
+
# No cached engine matches a requested spec.
|
|
17
|
+
class NotFound < Error; end
|
|
18
|
+
|
|
19
|
+
# More than one cached engine matches a requested spec.
|
|
20
|
+
class Ambiguous < Error; end
|
|
21
|
+
|
|
22
|
+
# An engine with the same id is already cached.
|
|
23
|
+
class AlreadyInstalled < Error; end
|
|
24
|
+
|
|
25
|
+
# The DragonRuby platform name for the machine talon is running on, as used
|
|
26
|
+
# in DragonRuby's zip and directory names (+dragonruby-linux-amd64+).
|
|
27
|
+
#
|
|
28
|
+
# @param host_os [String]
|
|
29
|
+
# @param host_cpu [String]
|
|
30
|
+
# @return [String]
|
|
31
|
+
def self.host_platform(host_os: RbConfig::CONFIG["host_os"], host_cpu: RbConfig::CONFIG["host_cpu"])
|
|
32
|
+
arch = /arm|aarch64/.match?(host_cpu) ? "arm64" : "amd64"
|
|
33
|
+
|
|
34
|
+
case host_os
|
|
35
|
+
when /darwin/ then "macos"
|
|
36
|
+
when /mswin|mingw|cygwin/ then "windows-#{arch}"
|
|
37
|
+
else "linux-#{arch}"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
require_relative "engine/installation"
|
|
44
|
+
require_relative "engine/spec"
|
|
45
|
+
require_relative "engine/inspector"
|
|
46
|
+
require_relative "engine/store"
|
|
47
|
+
require_relative "engine/importer"
|
|
48
|
+
require_relative "engine/materializer"
|
|
49
|
+
require_relative "engine/git_ignore"
|
|
50
|
+
require_relative "engine/fetchers"
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rbconfig"
|
|
4
|
+
|
|
5
|
+
module DrTalon
|
|
6
|
+
# Copies single files, cloning them (reflinks) where the filesystem allows so
|
|
7
|
+
# copies of the same engine into many projects share storage. Falls back to
|
|
8
|
+
# a byte copy across filesystems or on filesystems without cloning.
|
|
9
|
+
module FileCopy
|
|
10
|
+
# Linux +ioctl(dst, FICLONE, src)+; supported by btrfs and XFS.
|
|
11
|
+
FICLONE = 0x40049409
|
|
12
|
+
CLONE_UNSUPPORTED = [
|
|
13
|
+
Errno::EOPNOTSUPP, Errno::EXDEV, Errno::EINVAL, Errno::ENOTTY, Errno::EPERM, Errno::EBADF, NotImplementedError
|
|
14
|
+
].freeze
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# Copies +src+ to +dst+, keeping the mode and timestamps. An existing
|
|
19
|
+
# +dst+ (including a symlink) is replaced rather than written through.
|
|
20
|
+
#
|
|
21
|
+
# @param src [String]
|
|
22
|
+
# @param dst [String]
|
|
23
|
+
# @return [Boolean] whether the file was cloned rather than byte-copied
|
|
24
|
+
def copy(src, dst)
|
|
25
|
+
File.unlink(dst) if File.symlink?(dst) || File.exist?(dst)
|
|
26
|
+
|
|
27
|
+
cloned = File.open(src, "rb") do |input|
|
|
28
|
+
File.open(dst, File::WRONLY | File::CREAT | File::EXCL | File::BINARY) do |output|
|
|
29
|
+
try_clone(input, output).tap { |done| IO.copy_stream(input, output) unless done }
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
stat = File.stat(src)
|
|
34
|
+
File.chmod(stat.mode & 0o7777, dst)
|
|
35
|
+
File.utime(stat.atime, stat.mtime, dst)
|
|
36
|
+
cloned
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @return [Boolean] whether the clone succeeded
|
|
40
|
+
def try_clone(input, output)
|
|
41
|
+
return false unless RbConfig::CONFIG["host_os"].include?("linux")
|
|
42
|
+
|
|
43
|
+
output.ioctl(FICLONE, input.fileno)
|
|
44
|
+
true
|
|
45
|
+
rescue *CLONE_UNSUPPORTED
|
|
46
|
+
false
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|