toys-core 0.11.5 → 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 +62 -0
- data/LICENSE.md +1 -1
- data/README.md +5 -2
- data/docs/guide.md +1 -1
- data/lib/toys/acceptor.rb +13 -4
- data/lib/toys/arg_parser.rb +7 -7
- data/lib/toys/cli.rb +170 -120
- data/lib/toys/compat.rb +71 -23
- data/lib/toys/completion.rb +18 -6
- data/lib/toys/context.rb +24 -15
- data/lib/toys/core.rb +6 -2
- data/lib/toys/dsl/base.rb +87 -0
- data/lib/toys/dsl/flag.rb +26 -20
- data/lib/toys/dsl/flag_group.rb +18 -14
- data/lib/toys/dsl/internal.rb +206 -0
- data/lib/toys/dsl/positional_arg.rb +26 -16
- data/lib/toys/dsl/tool.rb +180 -218
- data/lib/toys/errors.rb +64 -8
- data/lib/toys/flag.rb +662 -656
- data/lib/toys/flag_group.rb +24 -10
- data/lib/toys/input_file.rb +13 -7
- data/lib/toys/loader.rb +293 -140
- data/lib/toys/middleware.rb +46 -22
- data/lib/toys/mixin.rb +10 -8
- data/lib/toys/positional_arg.rb +21 -20
- data/lib/toys/settings.rb +914 -0
- data/lib/toys/source_info.rb +147 -35
- data/lib/toys/standard_middleware/add_verbosity_flags.rb +2 -0
- data/lib/toys/standard_middleware/apply_config.rb +6 -4
- data/lib/toys/standard_middleware/handle_usage_errors.rb +1 -0
- data/lib/toys/standard_middleware/set_default_descriptions.rb +19 -18
- data/lib/toys/standard_middleware/show_help.rb +19 -5
- data/lib/toys/standard_middleware/show_root_version.rb +2 -0
- data/lib/toys/standard_mixins/bundler.rb +24 -15
- data/lib/toys/standard_mixins/exec.rb +43 -34
- 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 +46 -0
- 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 +56 -0
- data/lib/toys/template.rb +11 -9
- data/lib/toys/{tool.rb → tool_definition.rb} +292 -226
- data/lib/toys/utils/completion_engine.rb +7 -2
- data/lib/toys/utils/exec.rb +162 -132
- data/lib/toys/utils/gems.rb +85 -60
- data/lib/toys/utils/git_cache.rb +813 -0
- data/lib/toys/utils/help_text.rb +117 -37
- data/lib/toys/utils/terminal.rb +11 -3
- data/lib/toys/utils/xdg.rb +293 -0
- data/lib/toys/wrappable_string.rb +9 -2
- data/lib/toys-core.rb +18 -6
- metadata +14 -7
@@ -0,0 +1,813 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "digest"
|
4
|
+
require "fileutils"
|
5
|
+
require "json"
|
6
|
+
require "toys/utils/exec"
|
7
|
+
require "toys/utils/xdg"
|
8
|
+
|
9
|
+
module Toys
|
10
|
+
module Utils
|
11
|
+
##
|
12
|
+
# This object provides cached access to remote git data. Given a remote
|
13
|
+
# repository, a path, and a commit, it makes the files availble in the
|
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.
|
16
|
+
#
|
17
|
+
# This class is used by the Loader to load tools from git. Tools can also
|
18
|
+
# use the `:git_cache` mixin for direct access to this class.
|
19
|
+
#
|
20
|
+
class GitCache
|
21
|
+
##
|
22
|
+
# GitCache encountered a failure
|
23
|
+
#
|
24
|
+
class Error < ::StandardError
|
25
|
+
##
|
26
|
+
# Create a GitCache::Error.
|
27
|
+
#
|
28
|
+
# @param message [String] The error message
|
29
|
+
# @param result [Toys::Utils::Exec::Result] The result of a git
|
30
|
+
# command execution, or `nil` if this error was not due to a git
|
31
|
+
# command error.
|
32
|
+
#
|
33
|
+
def initialize(message, result)
|
34
|
+
super(message)
|
35
|
+
@exec_result = result
|
36
|
+
end
|
37
|
+
|
38
|
+
##
|
39
|
+
# @return [Toys::Utils::Exec::Result] The result of a git command
|
40
|
+
# execution, or `nil` if this error was not due to a git command
|
41
|
+
# error.
|
42
|
+
#
|
43
|
+
attr_reader :exec_result
|
44
|
+
end
|
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
|
+
|
283
|
+
##
|
284
|
+
# Access a git cache.
|
285
|
+
#
|
286
|
+
# @param cache_dir [String] The path to the cache directory. Defaults to
|
287
|
+
# a specific directory in the user's XDG cache.
|
288
|
+
#
|
289
|
+
def initialize(cache_dir: nil)
|
290
|
+
@cache_dir = ::File.expand_path(cache_dir || default_cache_dir)
|
291
|
+
@exec = Utils::Exec.new(out: :capture, err: :capture)
|
292
|
+
end
|
293
|
+
|
294
|
+
##
|
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
|
303
|
+
# remote repo if necessary.
|
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
|
+
#
|
314
|
+
# @param remote [String] The URL of the git repo. Required.
|
315
|
+
# @param path [String] The path to the file or directory within the repo.
|
316
|
+
# Optional. Defaults to the entire repo.
|
317
|
+
# @param commit [String] The commit reference, which may be a SHA or any
|
318
|
+
# git ref such as a branch or tag. Optional. Defaults to `HEAD`.
|
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`.
|
328
|
+
#
|
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.
|
333
|
+
#
|
334
|
+
def get(remote, path: nil, commit: nil, into: nil, update: false, timestamp: nil)
|
335
|
+
path = GitCache.normalize_path(path)
|
336
|
+
commit ||= "HEAD"
|
337
|
+
timestamp ||= ::Time.now.to_i
|
338
|
+
dir = ensure_repo_base_dir(remote)
|
339
|
+
lock_repo(dir, remote, timestamp) do |repo_lock|
|
340
|
+
ensure_repo(dir, remote)
|
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
|
347
|
+
end
|
348
|
+
end
|
349
|
+
alias find get
|
350
|
+
|
351
|
+
##
|
352
|
+
# Returns an array of the known remote names.
|
353
|
+
#
|
354
|
+
# @return [Array<String>]
|
355
|
+
#
|
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
|
369
|
+
|
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
|
383
|
+
end
|
384
|
+
|
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
|
410
|
+
|
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
|
436
|
+
end
|
437
|
+
|
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
|
474
|
+
end
|
475
|
+
|
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))
|
484
|
+
end
|
485
|
+
|
486
|
+
def default_cache_dir
|
487
|
+
::File.join(XDG.new.cache_home, "toys", "git")
|
488
|
+
end
|
489
|
+
|
490
|
+
def git(dir, cmd, error_message: nil)
|
491
|
+
result = @exec.exec(["git"] + cmd, chdir: dir)
|
492
|
+
if result.failed?
|
493
|
+
raise GitCache::Error.new("Could not run git command line", result)
|
494
|
+
end
|
495
|
+
if block_given?
|
496
|
+
yield result
|
497
|
+
elsif result.error? && error_message
|
498
|
+
raise GitCache::Error.new(error_message, result)
|
499
|
+
else
|
500
|
+
result
|
501
|
+
end
|
502
|
+
end
|
503
|
+
|
504
|
+
def ensure_repo_base_dir(remote)
|
505
|
+
dir = repo_base_dir_for(remote)
|
506
|
+
::FileUtils.mkdir_p(dir)
|
507
|
+
dir
|
508
|
+
end
|
509
|
+
|
510
|
+
def lock_repo(dir, remote = nil, timestamp = nil)
|
511
|
+
lock_path = ::File.join(dir, LOCK_FILE_NAME)
|
512
|
+
::File.open(lock_path, ::File::RDWR | ::File::CREAT) do |file|
|
513
|
+
file.flock(::File::LOCK_EX)
|
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
|
525
|
+
end
|
526
|
+
end
|
527
|
+
|
528
|
+
def ensure_repo(dir, remote)
|
529
|
+
repo_dir = ::File.join(dir, REPO_DIR_NAME)
|
530
|
+
::FileUtils.mkdir_p(repo_dir)
|
531
|
+
result = git(repo_dir, ["remote", "get-url", "origin"])
|
532
|
+
unless result.success? && result.captured_out.strip == remote
|
533
|
+
::FileUtils.chmod_R("u+w", repo_dir, force: true)
|
534
|
+
::FileUtils.rm_rf(repo_dir)
|
535
|
+
::FileUtils.mkdir_p(repo_dir)
|
536
|
+
git(repo_dir, ["init"],
|
537
|
+
error_message: "Unable to initialize git repository")
|
538
|
+
git(repo_dir, ["remote", "add", "origin", remote],
|
539
|
+
error_message: "Unable to add git remote: #{remote}")
|
540
|
+
end
|
541
|
+
end
|
542
|
+
|
543
|
+
def ensure_commit(dir, commit, repo_lock, update = false)
|
544
|
+
local_commit = "toys-git-cache/#{commit}"
|
545
|
+
repo_dir = ::File.join(dir, REPO_DIR_NAME)
|
546
|
+
is_sha = commit =~ /^[0-9a-f]{40}$/
|
547
|
+
update = repo_lock.ref_stale?(commit, update) unless is_sha
|
548
|
+
if update && !is_sha || !commit_exists?(repo_dir, local_commit)
|
549
|
+
git(repo_dir, ["fetch", "--depth=1", "--force", "origin", "#{commit}:#{local_commit}"],
|
550
|
+
error_message: "Unable to fetch commit: #{commit}")
|
551
|
+
repo_lock.update_ref!(commit)
|
552
|
+
end
|
553
|
+
result = git(repo_dir, ["rev-parse", local_commit],
|
554
|
+
error_message: "Unable to retrieve commit: #{local_commit}")
|
555
|
+
sha = result.captured_out.strip
|
556
|
+
repo_lock.access_ref!(commit, sha)
|
557
|
+
sha
|
558
|
+
end
|
559
|
+
|
560
|
+
def commit_exists?(repo_dir, commit)
|
561
|
+
result = git(repo_dir, ["cat-file", "-t", commit])
|
562
|
+
result.success? && result.captured_out.strip == "commit"
|
563
|
+
end
|
564
|
+
|
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("/")
|
809
|
+
end
|
810
|
+
end
|
811
|
+
end
|
812
|
+
end
|
813
|
+
end
|