toys-core 0.12.2 → 0.13.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/CHANGELOG.md +22 -0
- data/LICENSE.md +1 -1
- data/README.md +4 -1
- data/docs/guide.md +1 -1
- data/lib/toys/acceptor.rb +10 -1
- data/lib/toys/arg_parser.rb +1 -0
- data/lib/toys/cli.rb +127 -107
- data/lib/toys/compat.rb +54 -3
- data/lib/toys/completion.rb +15 -5
- data/lib/toys/context.rb +22 -20
- data/lib/toys/core.rb +6 -2
- data/lib/toys/dsl/base.rb +2 -0
- data/lib/toys/dsl/flag.rb +23 -17
- data/lib/toys/dsl/flag_group.rb +11 -7
- data/lib/toys/dsl/positional_arg.rb +23 -13
- data/lib/toys/dsl/tool.rb +10 -6
- data/lib/toys/errors.rb +63 -8
- data/lib/toys/flag.rb +660 -651
- data/lib/toys/flag_group.rb +19 -6
- data/lib/toys/input_file.rb +9 -3
- data/lib/toys/loader.rb +129 -115
- data/lib/toys/middleware.rb +45 -21
- data/lib/toys/mixin.rb +8 -6
- data/lib/toys/positional_arg.rb +18 -17
- data/lib/toys/settings.rb +81 -67
- data/lib/toys/source_info.rb +33 -24
- data/lib/toys/standard_middleware/add_verbosity_flags.rb +2 -0
- data/lib/toys/standard_middleware/apply_config.rb +1 -0
- data/lib/toys/standard_middleware/handle_usage_errors.rb +1 -0
- data/lib/toys/standard_middleware/set_default_descriptions.rb +1 -0
- data/lib/toys/standard_middleware/show_help.rb +2 -0
- data/lib/toys/standard_middleware/show_root_version.rb +2 -0
- data/lib/toys/standard_mixins/bundler.rb +22 -14
- data/lib/toys/standard_mixins/exec.rb +31 -20
- data/lib/toys/standard_mixins/fileutils.rb +3 -1
- data/lib/toys/standard_mixins/gems.rb +21 -17
- data/lib/toys/standard_mixins/git_cache.rb +5 -7
- data/lib/toys/standard_mixins/highline.rb +8 -8
- data/lib/toys/standard_mixins/terminal.rb +5 -5
- data/lib/toys/standard_mixins/xdg.rb +5 -5
- data/lib/toys/template.rb +9 -7
- data/lib/toys/tool_definition.rb +209 -202
- data/lib/toys/utils/completion_engine.rb +7 -2
- data/lib/toys/utils/exec.rb +158 -127
- data/lib/toys/utils/gems.rb +81 -57
- data/lib/toys/utils/git_cache.rb +674 -45
- data/lib/toys/utils/help_text.rb +27 -3
- data/lib/toys/utils/terminal.rb +10 -2
- data/lib/toys/wrappable_string.rb +9 -2
- data/lib/toys-core.rb +14 -5
- metadata +4 -4
data/lib/toys/utils/git_cache.rb
CHANGED
@@ -2,6 +2,7 @@
|
|
2
2
|
|
3
3
|
require "digest"
|
4
4
|
require "fileutils"
|
5
|
+
require "json"
|
5
6
|
require "toys/utils/exec"
|
6
7
|
require "toys/utils/xdg"
|
7
8
|
|
@@ -10,8 +11,8 @@ module Toys
|
|
10
11
|
##
|
11
12
|
# This object provides cached access to remote git data. Given a remote
|
12
13
|
# repository, a path, and a commit, it makes the files availble in the
|
13
|
-
# local filesystem. Access is cached, so repeated requests
|
14
|
-
# remote repository again.
|
14
|
+
# local filesystem. Access is cached, so repeated requests for the same
|
15
|
+
# commit and path in the same repo do not hit the remote repository again.
|
15
16
|
#
|
16
17
|
# This class is used by the Loader to load tools from git. Tools can also
|
17
18
|
# use the `:git_cache` mixin for direct access to this class.
|
@@ -42,6 +43,243 @@ module Toys
|
|
42
43
|
attr_reader :exec_result
|
43
44
|
end
|
44
45
|
|
46
|
+
##
|
47
|
+
# Information about a remote git repository in the cache.
|
48
|
+
#
|
49
|
+
# This object is returned from {GitCache#repo_info}.
|
50
|
+
#
|
51
|
+
class RepoInfo
|
52
|
+
include ::Comparable
|
53
|
+
|
54
|
+
##
|
55
|
+
# The base directory of this git repository's cache entry. This
|
56
|
+
# directory contains all cached data related to this repo. Deleting it
|
57
|
+
# effectively removes the repo from the cache.
|
58
|
+
#
|
59
|
+
# @return [String]
|
60
|
+
#
|
61
|
+
attr_reader :base_dir
|
62
|
+
|
63
|
+
##
|
64
|
+
# The git remote, usually a file system path or URL.
|
65
|
+
#
|
66
|
+
# @return [String]
|
67
|
+
#
|
68
|
+
attr_reader :remote
|
69
|
+
|
70
|
+
##
|
71
|
+
# The last time any cached data from this repo was accessed, or `nil`
|
72
|
+
# if the information is unavailable.
|
73
|
+
#
|
74
|
+
# @return [Time,nil]
|
75
|
+
#
|
76
|
+
attr_reader :last_accessed
|
77
|
+
|
78
|
+
##
|
79
|
+
# A list of git refs (branches, tags, shas) that have been accessed
|
80
|
+
# from this repo.
|
81
|
+
#
|
82
|
+
# @return [Array<RefInfo>]
|
83
|
+
#
|
84
|
+
attr_reader :refs
|
85
|
+
|
86
|
+
##
|
87
|
+
# A list of shared source files and directories accessed for this repo.
|
88
|
+
#
|
89
|
+
# @return [Array<SourceInfo>]
|
90
|
+
#
|
91
|
+
attr_reader :sources
|
92
|
+
|
93
|
+
##
|
94
|
+
# Convert this RepoInfo to a hash suitable for JSON output
|
95
|
+
#
|
96
|
+
# @return [Hash]
|
97
|
+
#
|
98
|
+
def to_h
|
99
|
+
result = {
|
100
|
+
"remote" => remote,
|
101
|
+
"base_dir" => base_dir,
|
102
|
+
}
|
103
|
+
result["last_accessed"] = last_accessed.to_i if last_accessed
|
104
|
+
result["refs"] = refs.map(&:to_h)
|
105
|
+
result["sources"] = sources.map(&:to_h)
|
106
|
+
result
|
107
|
+
end
|
108
|
+
|
109
|
+
##
|
110
|
+
# Comparison function
|
111
|
+
#
|
112
|
+
# @param other [RepoInfo]
|
113
|
+
# @return [Integer]
|
114
|
+
#
|
115
|
+
def <=>(other)
|
116
|
+
remote <=> other.remote
|
117
|
+
end
|
118
|
+
|
119
|
+
##
|
120
|
+
# @private
|
121
|
+
#
|
122
|
+
def initialize(base_dir, data)
|
123
|
+
@base_dir = base_dir
|
124
|
+
@remote = data["remote"]
|
125
|
+
accessed = data["accessed"]
|
126
|
+
@last_accessed = accessed ? ::Time.at(accessed).utc : nil
|
127
|
+
@refs = (data["refs"] || {}).map { |ref, ref_data| RefInfo.new(ref, ref_data) }
|
128
|
+
@sources = (data["sources"] || {}).flat_map do |sha, sha_data|
|
129
|
+
sha_data.map do |path, path_data|
|
130
|
+
SourceInfo.new(base_dir, sha, path, path_data)
|
131
|
+
end
|
132
|
+
end
|
133
|
+
@refs.sort!
|
134
|
+
@sources.sort!
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
##
|
139
|
+
# Information about a git ref used in a cache.
|
140
|
+
#
|
141
|
+
class RefInfo
|
142
|
+
include ::Comparable
|
143
|
+
|
144
|
+
##
|
145
|
+
# The git ref
|
146
|
+
#
|
147
|
+
# @return [String]
|
148
|
+
#
|
149
|
+
attr_reader :ref
|
150
|
+
|
151
|
+
##
|
152
|
+
# The git sha last associated with the ref
|
153
|
+
#
|
154
|
+
# @return [String]
|
155
|
+
#
|
156
|
+
attr_reader :sha
|
157
|
+
|
158
|
+
##
|
159
|
+
# The timestamp when this ref was last accessed
|
160
|
+
#
|
161
|
+
# @return [Time]
|
162
|
+
#
|
163
|
+
attr_reader :last_accessed
|
164
|
+
|
165
|
+
##
|
166
|
+
# The timestamp when this ref was last updated
|
167
|
+
#
|
168
|
+
# @return [Time]
|
169
|
+
#
|
170
|
+
attr_reader :last_updated
|
171
|
+
|
172
|
+
##
|
173
|
+
# Convert this RefInfo to a hash suitable for JSON output
|
174
|
+
#
|
175
|
+
# @return [Hash]
|
176
|
+
#
|
177
|
+
def to_h
|
178
|
+
result = {
|
179
|
+
"ref" => ref,
|
180
|
+
"sha" => sha,
|
181
|
+
}
|
182
|
+
result["last_accessed"] = last_accessed.to_i if last_accessed
|
183
|
+
result["last_updated"] = last_updated.to_i if last_updated
|
184
|
+
result
|
185
|
+
end
|
186
|
+
|
187
|
+
##
|
188
|
+
# Comparison function
|
189
|
+
#
|
190
|
+
# @param other [RefInfo]
|
191
|
+
# @return [Integer]
|
192
|
+
#
|
193
|
+
def <=>(other)
|
194
|
+
ref <=> other.ref
|
195
|
+
end
|
196
|
+
|
197
|
+
##
|
198
|
+
# @private
|
199
|
+
#
|
200
|
+
def initialize(ref, ref_data)
|
201
|
+
@ref = ref
|
202
|
+
@sha = ref_data["sha"]
|
203
|
+
@last_accessed = ref_data["accessed"]
|
204
|
+
@last_accessed = ::Time.at(@last_accessed).utc if @last_accessed
|
205
|
+
@last_updated = ref_data["updated"]
|
206
|
+
@last_updated = ::Time.at(@last_updated).utc if @last_updated
|
207
|
+
end
|
208
|
+
end
|
209
|
+
|
210
|
+
##
|
211
|
+
# Information about shared source files provided from the cache.
|
212
|
+
#
|
213
|
+
class SourceInfo
|
214
|
+
include ::Comparable
|
215
|
+
|
216
|
+
##
|
217
|
+
# The git sha the source comes from
|
218
|
+
#
|
219
|
+
# @return [String]
|
220
|
+
#
|
221
|
+
attr_reader :sha
|
222
|
+
|
223
|
+
##
|
224
|
+
# The path within the git repo
|
225
|
+
#
|
226
|
+
# @return [String]
|
227
|
+
#
|
228
|
+
attr_reader :git_path
|
229
|
+
|
230
|
+
##
|
231
|
+
# The path to the source file or directory
|
232
|
+
#
|
233
|
+
# @return [String]
|
234
|
+
#
|
235
|
+
attr_reader :source
|
236
|
+
|
237
|
+
##
|
238
|
+
# The timestamp when this ref was last accessed
|
239
|
+
#
|
240
|
+
# @return [Time]
|
241
|
+
#
|
242
|
+
attr_reader :last_accessed
|
243
|
+
|
244
|
+
##
|
245
|
+
# Convert this SourceInfo to a hash suitable for JSON output
|
246
|
+
#
|
247
|
+
# @return [Hash]
|
248
|
+
#
|
249
|
+
def to_h
|
250
|
+
result = {
|
251
|
+
"sha" => sha,
|
252
|
+
"git_path" => git_path,
|
253
|
+
"source" => source,
|
254
|
+
}
|
255
|
+
result["last_accessed"] = last_accessed.to_i if last_accessed
|
256
|
+
result
|
257
|
+
end
|
258
|
+
|
259
|
+
##
|
260
|
+
# Comparison function
|
261
|
+
#
|
262
|
+
# @param other [SourceInfo]
|
263
|
+
# @return [Integer]
|
264
|
+
#
|
265
|
+
def <=>(other)
|
266
|
+
result = sha <=> other.sha
|
267
|
+
result.zero? ? git_path <=> other.git_path : result
|
268
|
+
end
|
269
|
+
|
270
|
+
##
|
271
|
+
# @private
|
272
|
+
#
|
273
|
+
def initialize(base_dir, sha, git_path, path_data)
|
274
|
+
@sha = sha
|
275
|
+
@git_path = git_path
|
276
|
+
root_dir = ::File.join(base_dir, sha)
|
277
|
+
@source = git_path == "." ? root_dir : ::File.join(root_dir, git_path)
|
278
|
+
@last_accessed = path_data["accessed"]
|
279
|
+
@last_accessed = @last_accessed ? ::Time.at(@last_accessed).utc : nil
|
280
|
+
end
|
281
|
+
end
|
282
|
+
|
45
283
|
##
|
46
284
|
# Access a git cache.
|
47
285
|
#
|
@@ -54,55 +292,195 @@ module Toys
|
|
54
292
|
end
|
55
293
|
|
56
294
|
##
|
57
|
-
#
|
295
|
+
# The cache directory.
|
296
|
+
#
|
297
|
+
# @return [String]
|
298
|
+
#
|
299
|
+
attr_reader :cache_dir
|
300
|
+
|
301
|
+
##
|
302
|
+
# Get the given git-based files from the git cache, loading from the
|
58
303
|
# remote repo if necessary.
|
59
304
|
#
|
305
|
+
# The resulting files are either copied into a directory you provide in
|
306
|
+
# the `:into` parameter, or populated into a _shared_ source directory if
|
307
|
+
# you omit the `:info` parameter. In the latter case, it is important
|
308
|
+
# that you do not modify the returned files or directories, nor add or
|
309
|
+
# remove any files from the directories returned, to avoid confusing
|
310
|
+
# callers that could be given the same directory. If you need to make any
|
311
|
+
# modifications to the returned files, use `:into` to provide your own
|
312
|
+
# private directory.
|
313
|
+
#
|
60
314
|
# @param remote [String] The URL of the git repo. Required.
|
61
315
|
# @param path [String] The path to the file or directory within the repo.
|
62
316
|
# Optional. Defaults to the entire repo.
|
63
317
|
# @param commit [String] The commit reference, which may be a SHA or any
|
64
318
|
# git ref such as a branch or tag. Optional. Defaults to `HEAD`.
|
65
|
-
# @param
|
66
|
-
#
|
319
|
+
# @param into [String] If provided, copies the specified files into the
|
320
|
+
# given directory path. If omitted or `nil`, populates and returns a
|
321
|
+
# shared source file or directory.
|
322
|
+
# @param update [Boolean,Integer] Whether to update of non-SHA commit
|
323
|
+
# references if they were previously loaded. This is useful, for
|
324
|
+
# example, if the commit is `HEAD` or a branch name. Pass `true` or
|
325
|
+
# `false` to specify whether to update, or an integer to update if
|
326
|
+
# last update was done at least that many seconds ago. Default is
|
327
|
+
# `false`.
|
67
328
|
#
|
68
|
-
# @return [String] The full path to the cached files.
|
329
|
+
# @return [String] The full path to the cached files. The returned path
|
330
|
+
# will correspod to the path given. For example, if you provide the
|
331
|
+
# path `Gemfile` representing a single file in the repository, the
|
332
|
+
# returned path will point directly to the cached copy of that file.
|
69
333
|
#
|
70
|
-
def
|
71
|
-
path
|
334
|
+
def get(remote, path: nil, commit: nil, into: nil, update: false, timestamp: nil)
|
335
|
+
path = GitCache.normalize_path(path)
|
72
336
|
commit ||= "HEAD"
|
73
|
-
|
74
|
-
|
337
|
+
timestamp ||= ::Time.now.to_i
|
338
|
+
dir = ensure_repo_base_dir(remote)
|
339
|
+
lock_repo(dir, remote, timestamp) do |repo_lock|
|
75
340
|
ensure_repo(dir, remote)
|
76
|
-
sha = ensure_commit(dir, commit, update)
|
77
|
-
|
341
|
+
sha = ensure_commit(dir, commit, repo_lock, update)
|
342
|
+
if into
|
343
|
+
copy_files(dir, sha, path, repo_lock, into)
|
344
|
+
else
|
345
|
+
ensure_source(dir, sha, path, repo_lock)
|
346
|
+
end
|
78
347
|
end
|
79
348
|
end
|
349
|
+
alias find get
|
80
350
|
|
81
351
|
##
|
82
|
-
#
|
352
|
+
# Returns an array of the known remote names.
|
83
353
|
#
|
84
|
-
# @return [String]
|
354
|
+
# @return [Array<String>]
|
85
355
|
#
|
86
|
-
|
356
|
+
def remotes
|
357
|
+
result = []
|
358
|
+
return result unless ::File.directory?(cache_dir)
|
359
|
+
::Dir.entries(cache_dir).each do |child|
|
360
|
+
next if child.start_with?(".")
|
361
|
+
dir = ::File.join(cache_dir, child)
|
362
|
+
if ::File.file?(::File.join(dir, LOCK_FILE_NAME))
|
363
|
+
remote = lock_repo(dir, &:remote)
|
364
|
+
result << remote if remote
|
365
|
+
end
|
366
|
+
end
|
367
|
+
result.sort
|
368
|
+
end
|
87
369
|
|
88
|
-
|
89
|
-
|
90
|
-
|
370
|
+
##
|
371
|
+
# Returns a {RepoInfo} describing the cache for the given remote, or
|
372
|
+
# `nil` if the given remote has never been cached.
|
373
|
+
#
|
374
|
+
# @param remote [String] Remote name for a repo
|
375
|
+
# @return [RepoInfo,nil]
|
376
|
+
#
|
377
|
+
def repo_info(remote)
|
378
|
+
dir = repo_base_dir_for(remote)
|
379
|
+
return nil unless ::File.directory?(dir)
|
380
|
+
lock_repo(dir, remote) do |repo_lock|
|
381
|
+
RepoInfo.new(dir, repo_lock.data)
|
382
|
+
end
|
91
383
|
end
|
92
384
|
|
93
|
-
|
385
|
+
##
|
386
|
+
# Removes caches for the given repos, or all repos if specified.
|
387
|
+
#
|
388
|
+
# Removes all cache information for the specified repositories, including
|
389
|
+
# local clones and shared source directories. The next time these
|
390
|
+
# repositories are requested, they will be reloaded from the remote
|
391
|
+
# repository from scratch.
|
392
|
+
#
|
393
|
+
# Be careful not to remove repos that are currently in use by other
|
394
|
+
# GitCache clients.
|
395
|
+
#
|
396
|
+
# @param remotes [Array<String>,:all] The remotes to remove.
|
397
|
+
# @return [Array<String>] The remotes actually removed.
|
398
|
+
#
|
399
|
+
def remove_repos(remotes)
|
400
|
+
remotes = self.remotes if remotes.nil? || remotes == :all
|
401
|
+
Array(remotes).map do |remote|
|
402
|
+
dir = repo_base_dir_for(remote)
|
403
|
+
if ::File.directory?(dir)
|
404
|
+
::FileUtils.chmod_R("u+w", dir)
|
405
|
+
::FileUtils.rm_rf(dir)
|
406
|
+
remote
|
407
|
+
end
|
408
|
+
end.compact.sort
|
409
|
+
end
|
94
410
|
|
95
|
-
|
96
|
-
|
411
|
+
##
|
412
|
+
# Remove records of the given refs (i.e. branches, tags, or `HEAD`) from
|
413
|
+
# the given repository's cache. The next time those refs are requested,
|
414
|
+
# they will be pulled from the remote repo.
|
415
|
+
#
|
416
|
+
# If you provide the `refs:` argument, only those refs are removed.
|
417
|
+
# Otherwise, all refs are removed.
|
418
|
+
#
|
419
|
+
# @param remote [String] The repository
|
420
|
+
# @param refs [Array<String>] The refs to remove. Optional.
|
421
|
+
# @return [Array<RefInfo>,nil] The refs actually forgotten, or `nil` if
|
422
|
+
# the given repo is not in the cache.
|
423
|
+
#
|
424
|
+
def remove_refs(remote, refs: nil)
|
425
|
+
dir = repo_base_dir_for(remote)
|
426
|
+
return nil unless ::File.directory?(dir)
|
427
|
+
results = []
|
428
|
+
lock_repo(dir, remote) do |repo_lock|
|
429
|
+
refs = repo_lock.refs if refs.nil? || refs == :all
|
430
|
+
Array(refs).each do |ref|
|
431
|
+
ref_data = repo_lock.delete_ref!(ref)
|
432
|
+
results << RefInfo.new(ref, ref_data) if ref_data
|
433
|
+
end
|
434
|
+
end
|
435
|
+
results.sort
|
97
436
|
end
|
98
437
|
|
99
|
-
|
100
|
-
|
101
|
-
|
438
|
+
##
|
439
|
+
# Removes shared sources for the given cache. The next time a client
|
440
|
+
# requests them, the removed sources will be recopied from the repo.
|
441
|
+
#
|
442
|
+
# If you provide the `commits:` argument, only sources associated with
|
443
|
+
# those commits are removed. Otherwise, all sources are removed.
|
444
|
+
#
|
445
|
+
# Be careful not to remove sources that are currently in use by other
|
446
|
+
# GitCache clients.
|
447
|
+
#
|
448
|
+
# @param remote [String] The repository
|
449
|
+
# @param commits [Array<String>] Remove only the sources for the given
|
450
|
+
# commits. Optional.
|
451
|
+
# @return [Array<SourceInfo>,nil] The sources actually removed, or `nil`
|
452
|
+
# if the given repo is not in the cache.
|
453
|
+
#
|
454
|
+
def remove_sources(remote, commits: nil)
|
455
|
+
dir = repo_base_dir_for(remote)
|
456
|
+
return nil unless ::File.directory?(dir)
|
457
|
+
results = []
|
458
|
+
lock_repo(dir, remote) do |repo_lock|
|
459
|
+
commits = nil if commits == :all
|
460
|
+
shas = Array(commits).map { |ref| repo_lock.lookup_ref(ref) }.compact.uniq if commits
|
461
|
+
repo_lock.find_sources(shas: shas).each do |(sha, path)|
|
462
|
+
data = repo_lock.delete_source!(sha, path)
|
463
|
+
results << SourceInfo.new(dir, sha, path, data)
|
464
|
+
end
|
465
|
+
results.map(&:sha).uniq.each do |sha|
|
466
|
+
unless repo_lock.source_exists?(sha)
|
467
|
+
sha_dir = ::File.join(dir, sha)
|
468
|
+
::FileUtils.chmod_R("u+w", sha_dir, force: true)
|
469
|
+
::FileUtils.rm_rf(sha_dir)
|
470
|
+
end
|
471
|
+
end
|
472
|
+
end
|
473
|
+
results.sort
|
102
474
|
end
|
103
475
|
|
104
|
-
|
105
|
-
|
476
|
+
private
|
477
|
+
|
478
|
+
REPO_DIR_NAME = "repo"
|
479
|
+
LOCK_FILE_NAME = "repo.lock"
|
480
|
+
private_constant :REPO_DIR_NAME, :LOCK_FILE_NAME
|
481
|
+
|
482
|
+
def repo_base_dir_for(remote)
|
483
|
+
::File.join(@cache_dir, GitCache.remote_dir_name(remote))
|
106
484
|
end
|
107
485
|
|
108
486
|
def default_cache_dir
|
@@ -123,45 +501,60 @@ module Toys
|
|
123
501
|
end
|
124
502
|
end
|
125
503
|
|
126
|
-
def
|
127
|
-
dir =
|
504
|
+
def ensure_repo_base_dir(remote)
|
505
|
+
dir = repo_base_dir_for(remote)
|
128
506
|
::FileUtils.mkdir_p(dir)
|
129
507
|
dir
|
130
508
|
end
|
131
509
|
|
132
|
-
def lock_repo(dir)
|
133
|
-
lock_path = ::File.join(dir,
|
510
|
+
def lock_repo(dir, remote = nil, timestamp = nil)
|
511
|
+
lock_path = ::File.join(dir, LOCK_FILE_NAME)
|
134
512
|
::File.open(lock_path, ::File::RDWR | ::File::CREAT) do |file|
|
135
513
|
file.flock(::File::LOCK_EX)
|
136
|
-
|
514
|
+
file.rewind
|
515
|
+
repo_lock = RepoLock.new(file, remote, timestamp)
|
516
|
+
begin
|
517
|
+
yield repo_lock
|
518
|
+
ensure
|
519
|
+
if repo_lock.modified?
|
520
|
+
file.rewind
|
521
|
+
file.truncate(0)
|
522
|
+
repo_lock.dump(file)
|
523
|
+
end
|
524
|
+
end
|
137
525
|
end
|
138
526
|
end
|
139
527
|
|
140
528
|
def ensure_repo(dir, remote)
|
141
|
-
repo_dir = ::File.join(dir,
|
529
|
+
repo_dir = ::File.join(dir, REPO_DIR_NAME)
|
142
530
|
::FileUtils.mkdir_p(repo_dir)
|
143
531
|
result = git(repo_dir, ["remote", "get-url", "origin"])
|
144
532
|
unless result.success? && result.captured_out.strip == remote
|
533
|
+
::FileUtils.chmod_R("u+w", repo_dir, force: true)
|
145
534
|
::FileUtils.rm_rf(repo_dir)
|
146
535
|
::FileUtils.mkdir_p(repo_dir)
|
147
536
|
git(repo_dir, ["init"],
|
148
537
|
error_message: "Unable to initialize git repository")
|
149
538
|
git(repo_dir, ["remote", "add", "origin", remote],
|
150
|
-
error_message: "Unable to add git remote")
|
539
|
+
error_message: "Unable to add git remote: #{remote}")
|
151
540
|
end
|
152
541
|
end
|
153
542
|
|
154
|
-
def ensure_commit(dir, commit, update = false)
|
543
|
+
def ensure_commit(dir, commit, repo_lock, update = false)
|
155
544
|
local_commit = "toys-git-cache/#{commit}"
|
156
|
-
repo_dir = ::File.join(dir,
|
545
|
+
repo_dir = ::File.join(dir, REPO_DIR_NAME)
|
157
546
|
is_sha = commit =~ /^[0-9a-f]{40}$/
|
547
|
+
update = repo_lock.ref_stale?(commit, update) unless is_sha
|
158
548
|
if update && !is_sha || !commit_exists?(repo_dir, local_commit)
|
159
549
|
git(repo_dir, ["fetch", "--depth=1", "--force", "origin", "#{commit}:#{local_commit}"],
|
160
|
-
error_message: "Unable to
|
550
|
+
error_message: "Unable to fetch commit: #{commit}")
|
551
|
+
repo_lock.update_ref!(commit)
|
161
552
|
end
|
162
553
|
result = git(repo_dir, ["rev-parse", local_commit],
|
163
554
|
error_message: "Unable to retrieve commit: #{local_commit}")
|
164
|
-
result.captured_out.strip
|
555
|
+
sha = result.captured_out.strip
|
556
|
+
repo_lock.access_ref!(commit, sha)
|
557
|
+
sha
|
165
558
|
end
|
166
559
|
|
167
560
|
def commit_exists?(repo_dir, commit)
|
@@ -169,15 +562,251 @@ module Toys
|
|
169
562
|
result.success? && result.captured_out.strip == "commit"
|
170
563
|
end
|
171
564
|
|
172
|
-
def ensure_source(dir, sha, path)
|
173
|
-
|
174
|
-
|
175
|
-
|
176
|
-
|
177
|
-
|
178
|
-
|
565
|
+
def ensure_source(dir, sha, path, repo_lock)
|
566
|
+
repo_path = ::File.join(dir, REPO_DIR_NAME)
|
567
|
+
source_path = ::File.join(dir, sha)
|
568
|
+
unless repo_lock.source_exists?(sha, path)
|
569
|
+
::FileUtils.mkdir_p(source_path)
|
570
|
+
::FileUtils.chmod_R("u+w", source_path)
|
571
|
+
copy_from_repo(repo_path, source_path, sha, path)
|
572
|
+
::FileUtils.chmod_R("a-w", source_path)
|
573
|
+
end
|
574
|
+
repo_lock.access_source!(sha, path)
|
575
|
+
path == "." ? source_path : ::File.join(source_path, path)
|
576
|
+
end
|
577
|
+
|
578
|
+
def copy_files(dir, sha, path, repo_lock, into)
|
579
|
+
repo_path = ::File.join(dir, REPO_DIR_NAME)
|
580
|
+
::FileUtils.mkdir_p(into)
|
581
|
+
::FileUtils.chmod_R("u+w", into)
|
582
|
+
Compat.dir_children(into).each { |child| ::FileUtils.rm_rf(::File.join(into, child)) }
|
583
|
+
result = copy_from_repo(repo_path, into, sha, path)
|
584
|
+
repo_lock.access_repo!
|
585
|
+
result
|
586
|
+
end
|
587
|
+
|
588
|
+
def copy_from_repo(repo_dir, into, sha, path)
|
589
|
+
git(repo_dir, ["checkout", sha])
|
590
|
+
if path == "."
|
591
|
+
Compat.dir_children(repo_dir).each do |entry|
|
592
|
+
next if entry == ".git"
|
593
|
+
to_path = ::File.join(into, entry)
|
594
|
+
unless ::File.exist?(to_path)
|
595
|
+
from_path = ::File.join(repo_dir, entry)
|
596
|
+
::FileUtils.cp_r(from_path, to_path)
|
597
|
+
end
|
598
|
+
end
|
599
|
+
into
|
600
|
+
else
|
601
|
+
to_path = ::File.join(into, path)
|
602
|
+
unless ::File.exist?(to_path)
|
603
|
+
from_path = ::File.join(repo_dir, path)
|
604
|
+
::FileUtils.mkdir_p(::File.dirname(to_path))
|
605
|
+
::FileUtils.cp_r(from_path, to_path)
|
606
|
+
end
|
607
|
+
to_path
|
608
|
+
end
|
609
|
+
end
|
610
|
+
|
611
|
+
##
|
612
|
+
# An object that manages the lock data
|
613
|
+
#
|
614
|
+
# @private
|
615
|
+
#
|
616
|
+
class RepoLock
|
617
|
+
##
|
618
|
+
# @private
|
619
|
+
#
|
620
|
+
def initialize(io, remote, timestamp)
|
621
|
+
@data = ::JSON.parse(io.read) rescue {} # rubocop:disable Style/RescueModifier
|
622
|
+
@data["remote"] ||= remote
|
623
|
+
@data["refs"] ||= {}
|
624
|
+
@data["sources"] ||= {}
|
625
|
+
@modified = false
|
626
|
+
@timestamp = timestamp || ::Time.now.to_i
|
627
|
+
end
|
628
|
+
|
629
|
+
##
|
630
|
+
# @private
|
631
|
+
#
|
632
|
+
attr_reader :data
|
633
|
+
|
634
|
+
##
|
635
|
+
# @private
|
636
|
+
#
|
637
|
+
def modified?
|
638
|
+
@modified
|
639
|
+
end
|
640
|
+
|
641
|
+
##
|
642
|
+
# @private
|
643
|
+
#
|
644
|
+
def dump(io)
|
645
|
+
::JSON.dump(@data, io)
|
646
|
+
end
|
647
|
+
|
648
|
+
##
|
649
|
+
# @private
|
650
|
+
#
|
651
|
+
def remote
|
652
|
+
@data["remote"]
|
653
|
+
end
|
654
|
+
|
655
|
+
##
|
656
|
+
# @private
|
657
|
+
#
|
658
|
+
def refs
|
659
|
+
@data["refs"].keys
|
660
|
+
end
|
661
|
+
|
662
|
+
##
|
663
|
+
# @private
|
664
|
+
#
|
665
|
+
def lookup_ref(ref)
|
666
|
+
return ref if ref =~ /^[0-9a-f]{40}$/
|
667
|
+
@data["refs"][ref]&.fetch("sha", nil)
|
668
|
+
end
|
669
|
+
|
670
|
+
##
|
671
|
+
# @private
|
672
|
+
#
|
673
|
+
def ref_data(ref)
|
674
|
+
@data["refs"][ref]
|
675
|
+
end
|
676
|
+
|
677
|
+
##
|
678
|
+
# @private
|
679
|
+
#
|
680
|
+
def ref_stale?(ref, age)
|
681
|
+
ref_info = @data["refs"][ref]
|
682
|
+
last_updated = ref_info ? ref_info.fetch("updated", 0) : 0
|
683
|
+
return true if last_updated.zero?
|
684
|
+
return age unless age.is_a?(::Numeric)
|
685
|
+
@timestamp >= last_updated + age
|
686
|
+
end
|
687
|
+
|
688
|
+
##
|
689
|
+
# @private
|
690
|
+
#
|
691
|
+
def update_ref!(ref)
|
692
|
+
ref_info = @data["refs"][ref] ||= {}
|
693
|
+
is_first = !ref_info.key?("updated")
|
694
|
+
ref_info["updated"] = @timestamp
|
695
|
+
@modified = true
|
696
|
+
is_first
|
697
|
+
end
|
698
|
+
|
699
|
+
##
|
700
|
+
# @private
|
701
|
+
#
|
702
|
+
def delete_ref!(ref)
|
703
|
+
ref_data = @data["refs"].delete(ref)
|
704
|
+
@modified = true if ref_data
|
705
|
+
ref_data
|
706
|
+
end
|
707
|
+
|
708
|
+
##
|
709
|
+
# @private
|
710
|
+
#
|
711
|
+
def delete_source!(sha, path)
|
712
|
+
sha_data = @data["sources"][sha]
|
713
|
+
return nil if sha_data.nil?
|
714
|
+
source_data = sha_data.delete(path)
|
715
|
+
if source_data
|
716
|
+
@modified = true
|
717
|
+
@data["sources"].delete(sha) if sha_data.empty?
|
718
|
+
end
|
719
|
+
source_data
|
720
|
+
end
|
721
|
+
|
722
|
+
##
|
723
|
+
# @private
|
724
|
+
#
|
725
|
+
def access_ref!(ref, sha)
|
726
|
+
ref_info = @data["refs"][ref] ||= {}
|
727
|
+
ref_info["sha"] = sha
|
728
|
+
is_first = !ref_info.key?("accessed")
|
729
|
+
ref_info["accessed"] = @timestamp
|
730
|
+
@modified = true
|
731
|
+
is_first
|
732
|
+
end
|
733
|
+
|
734
|
+
##
|
735
|
+
# @private
|
736
|
+
#
|
737
|
+
def source_exists?(sha, path = nil)
|
738
|
+
sha_info = @data["sources"][sha]
|
739
|
+
path ? sha_info&.fetch(path, nil)&.key?("accessed") : !sha_info.nil?
|
740
|
+
end
|
741
|
+
|
742
|
+
##
|
743
|
+
# @private
|
744
|
+
#
|
745
|
+
def source_data(sha, path)
|
746
|
+
@data["sources"][sha]&.fetch(path, nil)
|
747
|
+
end
|
748
|
+
|
749
|
+
def find_sources(paths: nil, shas: nil)
|
750
|
+
results = []
|
751
|
+
@data["sources"].each do |sha, sha_data|
|
752
|
+
next unless shas.nil? || shas.include?(sha)
|
753
|
+
sha_data.each_key do |path|
|
754
|
+
next unless paths.nil? || paths.include?(path)
|
755
|
+
results << [sha, path]
|
756
|
+
end
|
757
|
+
end
|
758
|
+
results
|
759
|
+
end
|
760
|
+
|
761
|
+
##
|
762
|
+
# @private
|
763
|
+
#
|
764
|
+
def access_source!(sha, path)
|
765
|
+
@data["accessed"] = @timestamp
|
766
|
+
source_info = @data["sources"][sha] ||= {}
|
767
|
+
path_info = source_info[path] ||= {}
|
768
|
+
is_first = !path_info.key?("accessed")
|
769
|
+
path_info["accessed"] = @timestamp
|
770
|
+
@modified = true
|
771
|
+
is_first
|
772
|
+
end
|
773
|
+
|
774
|
+
##
|
775
|
+
# @private
|
776
|
+
#
|
777
|
+
def access_repo!
|
778
|
+
is_first = !@data.key?("accessed")
|
779
|
+
@data["accessed"] = @timestamp
|
780
|
+
@modified = true
|
781
|
+
is_first
|
782
|
+
end
|
783
|
+
end
|
784
|
+
|
785
|
+
class << self
|
786
|
+
##
|
787
|
+
# @private
|
788
|
+
#
|
789
|
+
def remote_dir_name(remote)
|
790
|
+
::Digest::MD5.hexdigest(remote)
|
791
|
+
end
|
792
|
+
|
793
|
+
##
|
794
|
+
# @private
|
795
|
+
#
|
796
|
+
def normalize_path(orig_path)
|
797
|
+
segs = []
|
798
|
+
orig_segs = orig_path.to_s.sub(%r{^/+}, "").split(%r{/+})
|
799
|
+
raise ::ArgumentError, "Path #{orig_path.inspect} reads .git directory" if orig_segs.first == ".git"
|
800
|
+
orig_segs.each do |seg|
|
801
|
+
if seg == ".."
|
802
|
+
raise ::ArgumentError, "Path #{orig_path.inspect} references its parent" if segs.empty?
|
803
|
+
segs.pop
|
804
|
+
elsif seg != "."
|
805
|
+
segs.push(seg)
|
806
|
+
end
|
807
|
+
end
|
808
|
+
segs.empty? ? "." : segs.join("/")
|
179
809
|
end
|
180
|
-
source_path
|
181
810
|
end
|
182
811
|
end
|
183
812
|
end
|