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,238 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module DrTalon
|
|
8
|
+
module Deps
|
|
9
|
+
# Where sources fetch from; tests swap in local fakes.
|
|
10
|
+
Context = Data.define(:http, :git, :github_url, :codeload_url) do
|
|
11
|
+
def self.default
|
|
12
|
+
new(http: DrTalon::HTTP.new, git: Git.new, github_url: "https://github.com", codeload_url: "https://codeload.github.com")
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# One entry in +dependencies.rb+: where it comes from and where it's
|
|
17
|
+
# vendored inside the game directory.
|
|
18
|
+
class Dependency
|
|
19
|
+
VENDOR = "vendor"
|
|
20
|
+
|
|
21
|
+
# @return [String] short name, used by `talon add/remove/update`
|
|
22
|
+
attr_reader :name
|
|
23
|
+
# @return [String] game-relative path the files are written to
|
|
24
|
+
attr_reader :destination
|
|
25
|
+
# @return [String, nil] requested branch, tag or commit
|
|
26
|
+
attr_reader :ref
|
|
27
|
+
# @return [Integer, nil] line in dependencies.rb that declared it
|
|
28
|
+
attr_reader :line
|
|
29
|
+
|
|
30
|
+
# @param name [String, nil] defaults to {#default_name}
|
|
31
|
+
# @param destination [String, nil] defaults to vendor/NAME
|
|
32
|
+
def initialize(name: nil, destination: nil, ref: nil, line: nil)
|
|
33
|
+
@ref = ref&.to_s
|
|
34
|
+
@line = line
|
|
35
|
+
@destination = destination&.to_s || "#{VENDOR}/#{name || default_name}"
|
|
36
|
+
@name = (name || (destination ? File.basename(@destination) : default_name)).to_s
|
|
37
|
+
validate_destination!
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @return [String] the lock key, in Foodchain's format where one exists
|
|
41
|
+
def key
|
|
42
|
+
raise NotImplementedError
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @return [Boolean] whether {#resolve} pins a version (path deps don't)
|
|
46
|
+
def lockable?
|
|
47
|
+
true
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The commit (or content digest) to install for the current {#ref}.
|
|
51
|
+
#
|
|
52
|
+
# @param context [Context]
|
|
53
|
+
# @return [String, nil]
|
|
54
|
+
def resolve(context)
|
|
55
|
+
raise NotImplementedError
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Writes the dependency's files for +commit+ to +target+.
|
|
59
|
+
#
|
|
60
|
+
# @param context [Context]
|
|
61
|
+
# @param commit [String, nil]
|
|
62
|
+
# @param target [String] absolute path; doesn't exist yet
|
|
63
|
+
# @return [void]
|
|
64
|
+
def fetch(context, commit, target)
|
|
65
|
+
raise NotImplementedError
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# @return [String] e.g. "github:asimmons91/talon (lib)"
|
|
69
|
+
def to_s
|
|
70
|
+
key
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def default_name
|
|
76
|
+
raise NotImplementedError
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def validate_destination!
|
|
80
|
+
parts = @destination.split("/")
|
|
81
|
+
return if parts.first == VENDOR && parts.size > 1 && !parts.include?("..") && !parts.include?("") &&
|
|
82
|
+
!@destination.start_with?("/")
|
|
83
|
+
|
|
84
|
+
raise ManifestError, "Dependency destination #{@destination.inspect} must be inside #{VENDOR}/, " \
|
|
85
|
+
"e.g. #{VENDOR}/#{File.basename(@destination)}. talon replaces it on install."
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def subpath_name(subpath, fallback)
|
|
89
|
+
base = File.basename(subpath.to_s)
|
|
90
|
+
(subpath.to_s.empty? || !base.include?(".")) ? fallback : base
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# A path inside a GitHub repository, downloaded as a tarball of the
|
|
95
|
+
# resolved commit.
|
|
96
|
+
class GitHubDependency < Dependency
|
|
97
|
+
attr_reader :owner, :repo, :subpath
|
|
98
|
+
|
|
99
|
+
def initialize(owner, repo, subpath: nil, **options)
|
|
100
|
+
@owner = owner.to_s
|
|
101
|
+
@repo = repo.to_s
|
|
102
|
+
@subpath = subpath.to_s.delete_prefix("/").delete_suffix("/")
|
|
103
|
+
super(**options)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def key
|
|
107
|
+
["github:#{owner}/#{repo}", (subpath unless subpath.empty?)].compact.join("/")
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def resolve(context)
|
|
111
|
+
context.git.resolve("#{context.github_url}/#{owner}/#{repo}.git", ref)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def fetch(context, commit, target)
|
|
115
|
+
data = context.http.get("#{context.codeload_url}/#{owner}/#{repo}/tar.gz/#{commit}")
|
|
116
|
+
Dir.mktmpdir("talon-github") do |dir|
|
|
117
|
+
Tree.untar(data, dir)
|
|
118
|
+
Tree.copy_subpath(dir, subpath, target)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
def default_name
|
|
125
|
+
subpath_name(subpath, repo)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# A path inside any git repository, fetched with the git CLI.
|
|
130
|
+
class GitDependency < Dependency
|
|
131
|
+
attr_reader :url, :subpath
|
|
132
|
+
|
|
133
|
+
def initialize(url, subpath: nil, **options)
|
|
134
|
+
@url = url.to_s
|
|
135
|
+
@subpath = subpath.to_s.delete_prefix("/").delete_suffix("/")
|
|
136
|
+
super(**options)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def key
|
|
140
|
+
subpath.empty? ? "git:#{url}" : "git:#{url}?path=#{subpath}"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def resolve(context)
|
|
144
|
+
context.git.resolve(url, ref)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def fetch(context, commit, target)
|
|
148
|
+
Dir.mktmpdir("talon-git") do |dir|
|
|
149
|
+
checkout = File.join(dir, "repo")
|
|
150
|
+
context.git.checkout(url, commit, checkout)
|
|
151
|
+
Tree.copy_subpath(checkout, subpath, target)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
private
|
|
156
|
+
|
|
157
|
+
def default_name
|
|
158
|
+
subpath_name(subpath, File.basename(url.chomp("/"), ".git"))
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# A single file downloaded from a URL, pinned by its SHA-256.
|
|
163
|
+
class URLDependency < Dependency
|
|
164
|
+
attr_reader :url
|
|
165
|
+
|
|
166
|
+
def initialize(url, **options)
|
|
167
|
+
@url = url.to_s
|
|
168
|
+
super(**options)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def key
|
|
172
|
+
url
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def resolve(context)
|
|
176
|
+
@body = context.http.get(url)
|
|
177
|
+
"sha256:#{Digest::SHA256.hexdigest(@body)}"
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def fetch(context, commit, target)
|
|
181
|
+
body = @body || context.http.get(url)
|
|
182
|
+
digest = "sha256:#{Digest::SHA256.hexdigest(body)}"
|
|
183
|
+
if commit && digest != commit
|
|
184
|
+
raise FetchError, "#{url} has changed since it was locked. Run `talon update #{name}` to accept the new version."
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
FileUtils.mkdir_p(File.dirname(target))
|
|
188
|
+
File.binwrite(target, body)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
private
|
|
192
|
+
|
|
193
|
+
def default_name
|
|
194
|
+
File.basename(URI(url).path)
|
|
195
|
+
rescue URI::InvalidURIError
|
|
196
|
+
raise ManifestError, "Invalid URL: #{url}"
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# A local file or directory, copied on every install. For developing a
|
|
201
|
+
# library alongside a game.
|
|
202
|
+
class PathDependency < Dependency
|
|
203
|
+
attr_reader :path
|
|
204
|
+
|
|
205
|
+
# @param path [String] as written in dependencies.rb
|
|
206
|
+
# @param base [String] what a relative +path+ is relative to
|
|
207
|
+
def initialize(path, base:, **options)
|
|
208
|
+
@path = path.to_s
|
|
209
|
+
@absolute = File.expand_path(@path, base)
|
|
210
|
+
super(**options)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def key
|
|
214
|
+
"path:#{path}"
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def lockable?
|
|
218
|
+
false
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def resolve(_context)
|
|
222
|
+
nil
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def fetch(_context, _commit, target)
|
|
226
|
+
raise FetchError, "#{@absolute} doesn't exist." unless File.exist?(@absolute)
|
|
227
|
+
|
|
228
|
+
Tree.copy_subpath(@absolute, "", target)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
private
|
|
232
|
+
|
|
233
|
+
def default_name
|
|
234
|
+
File.basename(@path.chomp("/"))
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "open3"
|
|
5
|
+
|
|
6
|
+
module DrTalon
|
|
7
|
+
module Deps
|
|
8
|
+
# Runs the git CLI, for resolving refs and fetching git dependencies.
|
|
9
|
+
class Git
|
|
10
|
+
FULL_SHA = /\A\h{40}\z/
|
|
11
|
+
|
|
12
|
+
# Resolves +ref+ (a branch, tag or full commit SHA; nil for the default
|
|
13
|
+
# branch) to a commit SHA without cloning.
|
|
14
|
+
#
|
|
15
|
+
# @param url [String]
|
|
16
|
+
# @param ref [String, nil]
|
|
17
|
+
# @return [String]
|
|
18
|
+
# @raise [FetchError]
|
|
19
|
+
def resolve(url, ref)
|
|
20
|
+
return ref.downcase if ref&.match?(FULL_SHA)
|
|
21
|
+
|
|
22
|
+
refs = ls_remote(url).to_h { |line| line.split("\t", 2).reverse }
|
|
23
|
+
candidates =
|
|
24
|
+
if ref.nil?
|
|
25
|
+
["HEAD"]
|
|
26
|
+
else
|
|
27
|
+
["refs/tags/#{ref}^{}", "refs/tags/#{ref}", "refs/heads/#{ref}"]
|
|
28
|
+
end
|
|
29
|
+
sha = candidates.filter_map { |name| refs[name] }.first
|
|
30
|
+
return sha if sha
|
|
31
|
+
|
|
32
|
+
hint = ref&.match?(/\A\h{4,39}\z/) ? " Abbreviated SHAs aren't supported; use all 40 characters." : ""
|
|
33
|
+
raise FetchError, "#{url} has no branch or tag named #{ref.inspect}.#{hint}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Checks out +commit+ of +url+ into +dir+ (which must not exist).
|
|
37
|
+
#
|
|
38
|
+
# @return [void]
|
|
39
|
+
# @raise [FetchError]
|
|
40
|
+
def checkout(url, commit, dir)
|
|
41
|
+
run("init", "--quiet", dir)
|
|
42
|
+
run("-C", dir, "fetch", "--quiet", "--depth", "1", url, commit)
|
|
43
|
+
run("-C", dir, "-c", "advice.detachedHead=false", "checkout", "--quiet", "FETCH_HEAD")
|
|
44
|
+
rescue FetchError
|
|
45
|
+
# Some servers don't allow fetching an arbitrary commit; fall back to a
|
|
46
|
+
# full clone.
|
|
47
|
+
FileUtils.rm_rf(dir)
|
|
48
|
+
run("clone", "--quiet", "--no-checkout", url, dir)
|
|
49
|
+
run("-C", dir, "-c", "advice.detachedHead=false", "checkout", "--quiet", commit)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def ls_remote(url)
|
|
55
|
+
run("ls-remote", url).lines(chomp: true)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def run(*args)
|
|
59
|
+
out, err, status = Open3.capture3("git", *args)
|
|
60
|
+
# git's first line says what went wrong; the rest is generic advice.
|
|
61
|
+
raise FetchError, "git #{args.first} failed: #{err.lines.map(&:strip).find { |l| !l.empty? }}" unless status.success?
|
|
62
|
+
|
|
63
|
+
out
|
|
64
|
+
rescue Errno::ENOENT
|
|
65
|
+
raise FetchError, "git isn't installed; it's needed for git and GitHub dependencies."
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
|
|
6
|
+
module DrTalon
|
|
7
|
+
module Deps
|
|
8
|
+
# Brings vendored files in line with +dependencies.rb+ and its locks.
|
|
9
|
+
#
|
|
10
|
+
# - Locked dependencies install their locked commit; new ones, ones whose
|
|
11
|
+
# ref or destination changed, and ones being updated are resolved again.
|
|
12
|
+
# - Vendored files are replaced only when they're unchanged since talon
|
|
13
|
+
# wrote them (or identical to what would be written); otherwise the
|
|
14
|
+
# install stops unless +force+.
|
|
15
|
+
# - Dependencies removed from the manifest have their files deleted.
|
|
16
|
+
# - Locks are written even when a later dependency fails, so finished work
|
|
17
|
+
# isn't reported as modified next time.
|
|
18
|
+
class Installer
|
|
19
|
+
# @!attribute [r] name
|
|
20
|
+
# @return [String]
|
|
21
|
+
# @!attribute [r] action
|
|
22
|
+
# @return [Symbol] +:installed+, +:updated+, +:unchanged+, +:restored+
|
|
23
|
+
# (local changes replaced with --force), +:removed+ or +:kept+
|
|
24
|
+
# (removed from the manifest but locally modified)
|
|
25
|
+
# @!attribute [r] dependency
|
|
26
|
+
# @return [Dependency, nil] nil for removals
|
|
27
|
+
# @!attribute [r] commit
|
|
28
|
+
# @return [String, nil]
|
|
29
|
+
# @!attribute [r] previous
|
|
30
|
+
# @return [String, nil] the commit before an update
|
|
31
|
+
Change = Data.define(:name, :action, :dependency, :commit, :previous)
|
|
32
|
+
|
|
33
|
+
# @param project [Project]
|
|
34
|
+
# @param context [Context]
|
|
35
|
+
# @param force [Boolean] overwrite or delete locally modified vendored files
|
|
36
|
+
def initialize(project, context: Context.default, force: false)
|
|
37
|
+
@project = project
|
|
38
|
+
@context = context
|
|
39
|
+
@force = force
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @param update [Array<String>, true, nil] names to re-resolve, or true for all
|
|
43
|
+
# @return [Array<Change>]
|
|
44
|
+
# @raise [ModifiedError] if vendored files were edited and +force+ is off
|
|
45
|
+
# @raise [Error] if +update+ names an unknown dependency
|
|
46
|
+
def install(update: nil)
|
|
47
|
+
manifest = Manifest.for(@project)
|
|
48
|
+
check_update_names!(manifest, update)
|
|
49
|
+
previous = manifest.locks.to_h { |lock| [lock.key, lock] }
|
|
50
|
+
written = {}
|
|
51
|
+
changes = []
|
|
52
|
+
|
|
53
|
+
begin
|
|
54
|
+
manifest.dependencies.each do |dependency|
|
|
55
|
+
lock, change = install_dependency(dependency, previous[dependency.key], updating?(dependency, update))
|
|
56
|
+
written[dependency.key] = lock
|
|
57
|
+
changes << change
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
(previous.keys - manifest.dependencies.map(&:key)).each do |key|
|
|
61
|
+
changes << remove(previous[key])
|
|
62
|
+
previous.delete(key)
|
|
63
|
+
end
|
|
64
|
+
ensure
|
|
65
|
+
# Keep locks for anything not reached (including removals not done
|
|
66
|
+
# yet), so a failure doesn't forget them.
|
|
67
|
+
unreached = previous.reject { |key, _| written.key?(key) }
|
|
68
|
+
manifest.write_locks(written.values + unreached.values) if manifest.exist? || written.any?
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
changes
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# @return [Array<Hash>] +dependency+, +locked+ and +latest+ commits of
|
|
75
|
+
# dependencies whose source has moved on
|
|
76
|
+
def outdated
|
|
77
|
+
manifest = Manifest.for(@project)
|
|
78
|
+
locks = manifest.locks.to_h { |lock| [lock.key, lock] }
|
|
79
|
+
|
|
80
|
+
manifest.dependencies.select(&:lockable?).filter_map do |dependency|
|
|
81
|
+
locked = locks[dependency.key]&.commit
|
|
82
|
+
latest = dependency.resolve(@context)
|
|
83
|
+
{dependency: dependency, locked: locked, latest: latest} if locked != latest
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Vendored dependencies whose files no longer match their locks.
|
|
88
|
+
#
|
|
89
|
+
# @return [Array<Hash>] +dependency+ and +problem+ (+:missing+,
|
|
90
|
+
# +:modified+ or +:unlocked+)
|
|
91
|
+
def problems
|
|
92
|
+
manifest = Manifest.for(@project)
|
|
93
|
+
locks = manifest.locks.to_h { |lock| [lock.key, lock] }
|
|
94
|
+
|
|
95
|
+
manifest.dependencies.filter_map do |dependency|
|
|
96
|
+
lock = locks[dependency.key]
|
|
97
|
+
checksum = Tree.checksum(game_path(dependency.destination))
|
|
98
|
+
problem =
|
|
99
|
+
if lock.nil? || lock.destination != dependency.destination then :unlocked
|
|
100
|
+
elsif checksum.nil? then :missing
|
|
101
|
+
elsif checksum != lock.checksum then :modified
|
|
102
|
+
end
|
|
103
|
+
{dependency: dependency, problem: problem} if problem
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
def check_update_names!(manifest, update)
|
|
110
|
+
return unless update.is_a?(Array)
|
|
111
|
+
|
|
112
|
+
unknown = update.reject { |name| manifest.find(name) }
|
|
113
|
+
raise Error, "Unknown dependenc#{(unknown.size == 1) ? "y" : "ies"}: #{unknown.join(", ")}" if unknown.any?
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def updating?(dependency, update)
|
|
117
|
+
update == true || (update.is_a?(Array) && update.any? { |name| [dependency.name, dependency.key].include?(name) })
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def install_dependency(dependency, lock, updating)
|
|
121
|
+
reusable = lock && !updating && dependency.lockable? &&
|
|
122
|
+
lock.ref == dependency.ref && lock.destination == dependency.destination
|
|
123
|
+
target = game_path(dependency.destination)
|
|
124
|
+
current = Tree.checksum(target)
|
|
125
|
+
|
|
126
|
+
if reusable && current && current == lock.checksum
|
|
127
|
+
return [lock, change(dependency, :unchanged, lock.commit)]
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
commit = reusable ? lock.commit : dependency.resolve(@context)
|
|
131
|
+
managed = lock && lock.destination == dependency.destination
|
|
132
|
+
|
|
133
|
+
staged_checksum = stage(dependency, commit) do |staged|
|
|
134
|
+
checksum = Tree.checksum(staged)
|
|
135
|
+
if current && current != checksum && (!managed || current != lock.checksum) && !@force
|
|
136
|
+
raise ModifiedError, "#{relative(target)} has changes talon didn't make. " \
|
|
137
|
+
"Move them aside, or pass --force to replace them with #{dependency.name}."
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
replace(target, staged) unless current == checksum
|
|
141
|
+
checksum
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
remove_path(game_path(lock.destination)) if lock && lock.destination != dependency.destination
|
|
145
|
+
|
|
146
|
+
new_lock = Lock.new(key: dependency.key, ref: dependency.ref, destination: dependency.destination,
|
|
147
|
+
commit: commit, checksum: staged_checksum)
|
|
148
|
+
action =
|
|
149
|
+
if lock.nil? || current.nil? then :installed
|
|
150
|
+
elsif lock.commit != commit then :updated
|
|
151
|
+
elsif current == staged_checksum then :unchanged
|
|
152
|
+
elsif dependency.lockable? then :restored
|
|
153
|
+
else :updated
|
|
154
|
+
end
|
|
155
|
+
[new_lock, change(dependency, action, commit, lock&.commit)]
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def remove(lock)
|
|
159
|
+
target = game_path(lock.destination)
|
|
160
|
+
current = Tree.checksum(target)
|
|
161
|
+
if current && current != lock.checksum && !@force
|
|
162
|
+
return Change.new(name: File.basename(lock.destination), action: :kept, dependency: nil, commit: nil, previous: nil)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
remove_path(target)
|
|
166
|
+
Change.new(name: File.basename(lock.destination), action: :removed, dependency: nil, commit: nil, previous: lock.commit)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def change(dependency, action, commit, previous = nil)
|
|
170
|
+
Change.new(name: dependency.name, action: action, dependency: dependency, commit: commit, previous: previous)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Fetches into a scratch directory on the project's filesystem and
|
|
174
|
+
# yields the fetched path.
|
|
175
|
+
def stage(dependency, commit)
|
|
176
|
+
scratch = @project.path(".talon")
|
|
177
|
+
FileUtils.mkdir_p(scratch)
|
|
178
|
+
Dir.mktmpdir("deps-", scratch) do |dir|
|
|
179
|
+
staged = File.join(dir, File.basename(dependency.destination))
|
|
180
|
+
dependency.fetch(@context, commit, staged)
|
|
181
|
+
yield staged
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def replace(target, staged)
|
|
186
|
+
FileUtils.rm_rf(target)
|
|
187
|
+
FileUtils.mkdir_p(File.dirname(target))
|
|
188
|
+
File.rename(staged, target)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def remove_path(path)
|
|
192
|
+
FileUtils.rm_rf(path)
|
|
193
|
+
# Tidy up an emptied vendor/ (but never the game directory itself).
|
|
194
|
+
dir = File.dirname(path)
|
|
195
|
+
Dir.rmdir(dir) if dir != @project.game_dir && File.directory?(dir) && Dir.empty?(dir)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def game_path(relative)
|
|
199
|
+
File.join(@project.game_dir, relative)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def relative(path)
|
|
203
|
+
path.delete_prefix("#{@project.root}/")
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|