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.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +22 -0
  3. data/LICENSE.md +1 -1
  4. data/README.md +4 -1
  5. data/docs/guide.md +1 -1
  6. data/lib/toys/acceptor.rb +10 -1
  7. data/lib/toys/arg_parser.rb +1 -0
  8. data/lib/toys/cli.rb +127 -107
  9. data/lib/toys/compat.rb +54 -3
  10. data/lib/toys/completion.rb +15 -5
  11. data/lib/toys/context.rb +22 -20
  12. data/lib/toys/core.rb +6 -2
  13. data/lib/toys/dsl/base.rb +2 -0
  14. data/lib/toys/dsl/flag.rb +23 -17
  15. data/lib/toys/dsl/flag_group.rb +11 -7
  16. data/lib/toys/dsl/positional_arg.rb +23 -13
  17. data/lib/toys/dsl/tool.rb +10 -6
  18. data/lib/toys/errors.rb +63 -8
  19. data/lib/toys/flag.rb +660 -651
  20. data/lib/toys/flag_group.rb +19 -6
  21. data/lib/toys/input_file.rb +9 -3
  22. data/lib/toys/loader.rb +129 -115
  23. data/lib/toys/middleware.rb +45 -21
  24. data/lib/toys/mixin.rb +8 -6
  25. data/lib/toys/positional_arg.rb +18 -17
  26. data/lib/toys/settings.rb +81 -67
  27. data/lib/toys/source_info.rb +33 -24
  28. data/lib/toys/standard_middleware/add_verbosity_flags.rb +2 -0
  29. data/lib/toys/standard_middleware/apply_config.rb +1 -0
  30. data/lib/toys/standard_middleware/handle_usage_errors.rb +1 -0
  31. data/lib/toys/standard_middleware/set_default_descriptions.rb +1 -0
  32. data/lib/toys/standard_middleware/show_help.rb +2 -0
  33. data/lib/toys/standard_middleware/show_root_version.rb +2 -0
  34. data/lib/toys/standard_mixins/bundler.rb +22 -14
  35. data/lib/toys/standard_mixins/exec.rb +31 -20
  36. data/lib/toys/standard_mixins/fileutils.rb +3 -1
  37. data/lib/toys/standard_mixins/gems.rb +21 -17
  38. data/lib/toys/standard_mixins/git_cache.rb +5 -7
  39. data/lib/toys/standard_mixins/highline.rb +8 -8
  40. data/lib/toys/standard_mixins/terminal.rb +5 -5
  41. data/lib/toys/standard_mixins/xdg.rb +5 -5
  42. data/lib/toys/template.rb +9 -7
  43. data/lib/toys/tool_definition.rb +209 -202
  44. data/lib/toys/utils/completion_engine.rb +7 -2
  45. data/lib/toys/utils/exec.rb +158 -127
  46. data/lib/toys/utils/gems.rb +81 -57
  47. data/lib/toys/utils/git_cache.rb +674 -45
  48. data/lib/toys/utils/help_text.rb +27 -3
  49. data/lib/toys/utils/terminal.rb +10 -2
  50. data/lib/toys/wrappable_string.rb +9 -2
  51. data/lib/toys-core.rb +14 -5
  52. metadata +4 -4
@@ -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 do not hit the
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
- # Find the given git-based files from the git cache, loading from the
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 update [Boolean] Force update of non-SHA commit references, even
66
- # if it has previously been loaded.
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 find(remote, path: nil, commit: nil, update: false)
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
- dir = ensure_dir(remote)
74
- lock_repo(dir) do
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
- ensure_source(dir, sha, path.to_s)
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
- # The cache directory.
352
+ # Returns an array of the known remote names.
83
353
  #
84
- # @return [String]
354
+ # @return [Array<String>]
85
355
  #
86
- attr_reader :cache_dir
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
- # @private Used for testing
89
- def repo_dir_for(remote)
90
- ::File.join(@cache_dir, remote_dir_name(remote), "repo")
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
- private
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
- def remote_dir_name(remote)
96
- ::Digest::MD5.hexdigest(remote)
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
- def source_name(sha, path)
100
- digest = ::Digest::MD5.hexdigest("#{sha}#{path}")
101
- "#{digest}#{::File.extname(path)}"
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
- def repo_dir_name
105
- "repo"
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 ensure_dir(remote)
127
- dir = ::File.join(@cache_dir, remote_dir_name(remote))
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, "repo.lock")
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
- yield
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, repo_dir_name)
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, repo_dir_name)
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 to fetch commit: #{commit}")
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
- source_path = ::File.join(dir, source_name(sha, path))
174
- unless ::File.exist?(source_path)
175
- repo_dir = ::File.join(dir, repo_dir_name)
176
- git(repo_dir, ["checkout", sha])
177
- from_path = ::File.join(repo_dir, path)
178
- ::FileUtils.cp_r(from_path, source_path)
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