toys-core 0.12.2 → 0.13.0

Sign up to get free protection for your applications and to get access to all the features.
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