git_cache 0.0.0 → 0.1.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1f0fb15343a6dac868fc7a80b0f0d38d7fb14d5f51606359e6ea4c201f659a23
4
- data.tar.gz: 0a5fb922ec153f77e4fa13297d4b8ef09e11c1ba064911944c3e2b0b1d9c9f14
3
+ metadata.gz: d3b7101169665dc9115fad2454928336b6ef83cca3b1db38a8ae6acef81062b1
4
+ data.tar.gz: f58df6572f0e3511ef30e4e7f1b0843e54ee59b157edb77d84f268b8a4bc0135
5
5
  SHA512:
6
- metadata.gz: 2579bbf410c7db0bcc565aa11fe1df93d8a332453b02bd59d15b2703d8caa3e3936ab260c00d91c092ad986bc28fd5be9966478cd5f23f76dcff8dfb07149b78
7
- data.tar.gz: 6ff682939c9363f8b427a17c0f141ba4657958494a0944c6afce2949c8e19acc83f81b075147e333ce1751436068ba0bb26cff4c735a4bab6ca2fdf3e0ead269
6
+ metadata.gz: 0be87372ec841eb2288330508ce7bd5aaa61cb7f1332b78ff4866f63c071534204309dfb2bc4417bb47f093901dc22063cf1ba5c84d9f0f0391addf660fa0b91
7
+ data.tar.gz: 45fa8853c29cc760cd59f3054a39d43bf13d72b5fc9f2afcb4cdf5460a3b30b96a212754d6c878d08d418986cf2858a1108c1c68e5b191b3a437ed76e0d775b7
data/.yardopts ADDED
@@ -0,0 +1,11 @@
1
+ --no-private
2
+ --title=GitCache
3
+ --markup=markdown
4
+ --markup-provider redcarpet
5
+ --main=README.md
6
+ ./lib/git_cache/**/*.rb
7
+ ./lib/git_cache.rb
8
+ -
9
+ README.md
10
+ LICENSE.md
11
+ CHANGELOG.md
data/CHANGELOG.md ADDED
@@ -0,0 +1,9 @@
1
+ # Release History
2
+
3
+ ### v0.1.1 / 2026-05-05
4
+
5
+ * BREAKING CHANGE: Make GitCache.sources_writable? private for now
6
+
7
+ ### v0.1.0 / 2026-05-04
8
+
9
+ * ADDED: Initial extraction from toys-core
data/LICENSE.md ADDED
@@ -0,0 +1,21 @@
1
+ # License
2
+
3
+ Copyright 2026 Daniel Azuma
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
20
+ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
21
+ IN THE SOFTWARE.
data/README.md CHANGED
@@ -1,9 +1,68 @@
1
- # Placeholder for git_cache
1
+ # GitCache
2
2
 
3
- This is a placeholder gem, which was generated on 2026-05-04 to
4
- reserve the gem "git_cache".
5
- The actual gem is planned for release in the near future.
3
+ The `GitCache` class provides cached access to remote git data. Given a remote
4
+ repository, a path, and a commit, it makes the files from that repository
5
+ available in the local file system. Access is cached, so repeated requests for
6
+ the same commit and path in the same repo do not make additional network calls.
6
7
 
7
- If this is a problem, or if the actual gem has not been released
8
- in a timely manner, you can contact the owner at
9
- `dazuma@gmail.com`.
8
+ ## Getting started
9
+
10
+ Install `GitCache` via the
11
+ [git_cache gem](https://rubygems.org/gems/git_cache).
12
+
13
+ ```sh
14
+ % gem install git_cache
15
+ ```
16
+
17
+ or add it to your Gemfile:
18
+
19
+ ```ruby
20
+ gem "git_cache"
21
+ ```
22
+
23
+ To use the service, instantiate `GitCache`, and call the `get` method to access
24
+ files from a repository, pulling them from the remote if necessary:
25
+
26
+ ```ruby
27
+ require "git_cache"
28
+ git_cache = GitCache.new
29
+ readme_path = git_cache.get("https://github.com/dazuma/git_cache.git",
30
+ path: "README.md")
31
+ puts File.read(readme_path)
32
+ ```
33
+
34
+ ## Contributing
35
+
36
+ Development is done in GitHub at https://github.com/dazuma/git_cache.
37
+
38
+ * To file issues: https://github.com/dazuma/git_cache/issues.
39
+ * For questions and discussion, please do not file an issue. Instead, use the
40
+ discussions feature: https://github.com/dazuma/git_cache/discussions.
41
+ * Pull requests are welcome, but in general please open an issue first before
42
+ contributing significant changes.
43
+
44
+ The library uses [toys](https://dazuma.github.io/toys) for testing and CI. To
45
+ run the test suite, `gem install toys` and then run `toys ci`. You can also run
46
+ unit tests, rubocop, and build tests independently.
47
+
48
+ ## License
49
+
50
+ Copyright 2026 Daniel Azuma
51
+
52
+ Permission is hereby granted, free of charge, to any person obtaining a copy
53
+ of this software and associated documentation files (the "Software"), to deal
54
+ in the Software without restriction, including without limitation the rights
55
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
56
+ copies of the Software, and to permit persons to whom the Software is
57
+ furnished to do so, subject to the following conditions:
58
+
59
+ The above copyright notice and this permission notice shall be included in
60
+ all copies or substantial portions of the Software.
61
+
62
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
63
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
64
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
65
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
66
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
67
+ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
68
+ IN THE SOFTWARE.
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ class GitCache
4
+ ##
5
+ # GitCache encountered a failure
6
+ #
7
+ class Error < ::StandardError
8
+ ##
9
+ # Create a GitCache::Error.
10
+ #
11
+ # @param message [String] The error message
12
+ # @param result [::ExecService::Result] The result of a git
13
+ # command execution, or `nil` if this error was not due to a git
14
+ # command error.
15
+ #
16
+ def initialize(message, result)
17
+ super(message)
18
+ @exec_result = result
19
+ end
20
+
21
+ ##
22
+ # @return [::ExecService::Result] The result of a git command
23
+ # execution, or `nil` if this error was not due to a git command
24
+ # error.
25
+ #
26
+ attr_reader :exec_result
27
+ end
28
+ end
@@ -0,0 +1,256 @@
1
+ # frozen_string_literal: true
2
+
3
+ class GitCache
4
+ ##
5
+ # Information about a remote git repository in the cache.
6
+ #
7
+ # This object is returned from {GitCache#repo_info}.
8
+ #
9
+ class RepoInfo
10
+ include ::Comparable
11
+
12
+ ##
13
+ # The base directory of this git repository's cache entry. This
14
+ # directory contains all cached data related to this repo. Deleting it
15
+ # effectively removes the repo from the cache.
16
+ #
17
+ # @return [String]
18
+ #
19
+ attr_reader :base_dir
20
+
21
+ ##
22
+ # The git remote, usually a file system path or URL.
23
+ #
24
+ # @return [String]
25
+ #
26
+ attr_reader :remote
27
+
28
+ ##
29
+ # The last time any cached data from this repo was accessed, or `nil`
30
+ # if the information is unavailable.
31
+ #
32
+ # @return [Time,nil]
33
+ #
34
+ attr_reader :last_accessed
35
+
36
+ ##
37
+ # A list of git refs (branches, tags, shas) that have been accessed
38
+ # from this repo.
39
+ #
40
+ # @param ref [String,nil] If provided, return only entries matching
41
+ # this ref name. If omitted, return all entries.
42
+ # @return [Array<RefInfo>]
43
+ #
44
+ def refs(ref: nil)
45
+ return @refs.dup if ref.nil?
46
+ @refs.find_all { |elem| elem.ref == ref }
47
+ end
48
+
49
+ ##
50
+ # A list of shared source files and directories accessed for this repo.
51
+ #
52
+ # @param sha [String,nil] If provided, return only entries matching
53
+ # this SHA. If omitted, entries for all SHAs are included.
54
+ # @param git_path [String,nil] If provided, return only entries
55
+ # matching this git path. If omitted, entries for all paths are
56
+ # included.
57
+ # @return [Array<SourceInfo>]
58
+ #
59
+ def sources(sha: nil, git_path: nil)
60
+ return @sources.dup if sha.nil? && git_path.nil?
61
+ @sources.find_all do |elem|
62
+ (sha.nil? || elem.sha == sha) &&
63
+ (git_path.nil? || elem.git_path == git_path)
64
+ end
65
+ end
66
+
67
+ ##
68
+ # Convert this RepoInfo to a hash suitable for JSON output
69
+ #
70
+ # @return [Hash]
71
+ #
72
+ def to_h
73
+ result = {
74
+ "remote" => remote,
75
+ "base_dir" => base_dir,
76
+ }
77
+ result["last_accessed"] = last_accessed.to_i if last_accessed
78
+ result["refs"] = refs.map(&:to_h)
79
+ result["sources"] = sources.map(&:to_h)
80
+ result
81
+ end
82
+
83
+ ##
84
+ # Comparison function
85
+ #
86
+ # @param other [RepoInfo]
87
+ # @return [Integer]
88
+ #
89
+ def <=>(other)
90
+ remote <=> other.remote
91
+ end
92
+
93
+ ##
94
+ # @private
95
+ #
96
+ def initialize(base_dir, data)
97
+ @base_dir = base_dir
98
+ @remote = data["remote"]
99
+ accessed = data["accessed"]
100
+ @last_accessed = accessed ? ::Time.at(accessed).utc : nil
101
+ @refs = (data["refs"] || {}).map { |ref, ref_data| RefInfo.new(ref, ref_data) }
102
+ @sources = (data["sources"] || {}).flat_map do |sha, sha_data|
103
+ sha_data.map do |path, path_data|
104
+ SourceInfo.new(base_dir, sha, path, path_data)
105
+ end
106
+ end
107
+ @refs.sort!
108
+ @sources.sort!
109
+ end
110
+ end
111
+
112
+ ##
113
+ # Information about a git ref used in a cache.
114
+ #
115
+ class RefInfo
116
+ include ::Comparable
117
+
118
+ ##
119
+ # The git ref
120
+ #
121
+ # @return [String]
122
+ #
123
+ attr_reader :ref
124
+
125
+ ##
126
+ # The git sha last associated with the ref
127
+ #
128
+ # @return [String]
129
+ #
130
+ attr_reader :sha
131
+
132
+ ##
133
+ # The timestamp when this ref was last accessed
134
+ #
135
+ # @return [Time,nil]
136
+ #
137
+ attr_reader :last_accessed
138
+
139
+ ##
140
+ # The timestamp when this ref was last updated
141
+ #
142
+ # @return [Time,nil]
143
+ #
144
+ attr_reader :last_updated
145
+
146
+ ##
147
+ # Convert this RefInfo to a hash suitable for JSON output
148
+ #
149
+ # @return [Hash]
150
+ #
151
+ def to_h
152
+ result = {
153
+ "ref" => ref,
154
+ "sha" => sha,
155
+ }
156
+ result["last_accessed"] = last_accessed.to_i if last_accessed
157
+ result["last_updated"] = last_updated.to_i if last_updated
158
+ result
159
+ end
160
+
161
+ ##
162
+ # Comparison function
163
+ #
164
+ # @param other [RefInfo]
165
+ # @return [Integer]
166
+ #
167
+ def <=>(other)
168
+ ref <=> other.ref
169
+ end
170
+
171
+ ##
172
+ # @private
173
+ #
174
+ def initialize(ref, ref_data)
175
+ @ref = ref
176
+ @sha = ref_data["sha"]
177
+ @last_accessed = ref_data["accessed"]
178
+ @last_accessed = ::Time.at(@last_accessed).utc if @last_accessed
179
+ @last_updated = ref_data["updated"]
180
+ @last_updated = ::Time.at(@last_updated).utc if @last_updated
181
+ end
182
+ end
183
+
184
+ ##
185
+ # Information about shared source files provided from the cache.
186
+ #
187
+ class SourceInfo
188
+ include ::Comparable
189
+
190
+ ##
191
+ # The git sha the source comes from
192
+ #
193
+ # @return [String]
194
+ #
195
+ attr_reader :sha
196
+
197
+ ##
198
+ # The path within the git repo
199
+ #
200
+ # @return [String]
201
+ #
202
+ attr_reader :git_path
203
+
204
+ ##
205
+ # The path to the source file or directory
206
+ #
207
+ # @return [String]
208
+ #
209
+ attr_reader :source
210
+
211
+ ##
212
+ # The timestamp when this ref was last accessed
213
+ #
214
+ # @return [Time,nil]
215
+ #
216
+ attr_reader :last_accessed
217
+
218
+ ##
219
+ # Convert this SourceInfo to a hash suitable for JSON output
220
+ #
221
+ # @return [Hash]
222
+ #
223
+ def to_h
224
+ result = {
225
+ "sha" => sha,
226
+ "git_path" => git_path,
227
+ "source" => source,
228
+ }
229
+ result["last_accessed"] = last_accessed.to_i if last_accessed
230
+ result
231
+ end
232
+
233
+ ##
234
+ # Comparison function
235
+ #
236
+ # @param other [SourceInfo]
237
+ # @return [Integer]
238
+ #
239
+ def <=>(other)
240
+ result = sha <=> other.sha
241
+ result.zero? ? git_path <=> other.git_path : result
242
+ end
243
+
244
+ ##
245
+ # @private
246
+ #
247
+ def initialize(base_dir, sha, git_path, path_data)
248
+ @sha = sha
249
+ @git_path = git_path
250
+ root_dir = ::File.join(base_dir, sha)
251
+ @source = ::GitCache.safe_join(root_dir, git_path)
252
+ @last_accessed = path_data["accessed"]
253
+ @last_accessed = @last_accessed ? ::Time.at(@last_accessed).utc : nil
254
+ end
255
+ end
256
+ end
@@ -0,0 +1,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ class GitCache
4
+ ##
5
+ # Associated with each repo (remote) is a lock file that saves the status
6
+ # of the cache, and also serves as a file system lock for updates to the
7
+ # repo. This is handled by the lock_repo method.
8
+ #
9
+ # This object represents the state of the repo, and is made available to
10
+ # the block passed to lock_repo. It has the following schema:
11
+ #
12
+ # remote: (String) # the remote url
13
+ # accessed: (Integer) # last accessed timestamp
14
+ # refs:
15
+ # (String): # git ref
16
+ # sha: (String) # resolved sha
17
+ # updated: (Integer) # last updated timestamp
18
+ # accessed: (Integer) # last accessed timestamp
19
+ # sources:
20
+ # (String): # sha of the shared source
21
+ # (String): # path populated
22
+ # accessed: (Integer) # last accessed timestamp
23
+ #
24
+ # @private
25
+ #
26
+ class RepoLock
27
+ ##
28
+ # @private
29
+ #
30
+ def initialize(io, remote, timestamp)
31
+ @data = ::JSON.parse(io.read) rescue {} # rubocop:disable Style/RescueModifier
32
+ @data["remote"] ||= remote
33
+ @data["refs"] ||= {}
34
+ @data["sources"] ||= {}
35
+ @modified = false
36
+ @timestamp = timestamp || ::Time.now.to_i
37
+ end
38
+
39
+ ##
40
+ # @private
41
+ #
42
+ attr_reader :data
43
+
44
+ ##
45
+ # @private
46
+ #
47
+ def modified?
48
+ @modified
49
+ end
50
+
51
+ ##
52
+ # @private
53
+ #
54
+ def dump(io)
55
+ ::JSON.dump(@data, io)
56
+ end
57
+
58
+ ##
59
+ # @private
60
+ #
61
+ def remote
62
+ @data["remote"]
63
+ end
64
+
65
+ ##
66
+ # @private
67
+ #
68
+ def refs
69
+ @data["refs"].keys
70
+ end
71
+
72
+ ##
73
+ # @private
74
+ #
75
+ def lookup_ref(ref)
76
+ return ref if ::GitCache.valid_sha?(ref)
77
+ @data["refs"][ref]&.fetch("sha", nil)
78
+ end
79
+
80
+ ##
81
+ # @private
82
+ #
83
+ def ref_data(ref)
84
+ @data["refs"][ref]
85
+ end
86
+
87
+ ##
88
+ # @private
89
+ #
90
+ def ref_stale?(ref, age)
91
+ ref_info = @data["refs"][ref]
92
+ last_updated = ref_info ? ref_info.fetch("updated", 0) : 0
93
+ return true if last_updated.zero?
94
+ return age unless age.is_a?(::Numeric)
95
+ @timestamp >= last_updated + age
96
+ end
97
+
98
+ ##
99
+ # @private
100
+ #
101
+ def source_exists?(sha, path = nil)
102
+ sha_info = @data["sources"][sha]
103
+ return false if sha_info.nil?
104
+ return true if path.nil?
105
+ sha_info.key?(path) || sha_info.key?(".") ||
106
+ sha_info.keys.any? { |existing_path| path.start_with?("#{existing_path}/") }
107
+ end
108
+
109
+ ##
110
+ # @private
111
+ #
112
+ def source_data(sha, path)
113
+ @data["sources"][sha]&.fetch(path, nil)
114
+ end
115
+
116
+ ##
117
+ # @private
118
+ #
119
+ def find_sources(paths: nil, shas: nil)
120
+ results = []
121
+ @data["sources"].each do |sha, sha_data|
122
+ next unless shas.nil? || shas.include?(sha)
123
+ sha_data.each_key do |path|
124
+ next unless paths.nil? || paths.include?(path)
125
+ results << [sha, path]
126
+ end
127
+ end
128
+ results
129
+ end
130
+
131
+ ##
132
+ # @private
133
+ #
134
+ def access_repo!
135
+ is_first = !@data.key?("accessed")
136
+ @data["accessed"] = @timestamp
137
+ @modified = true
138
+ is_first
139
+ end
140
+
141
+ ##
142
+ # @private
143
+ #
144
+ def access_ref!(ref, sha)
145
+ ref_info = @data["refs"][ref] ||= {}
146
+ ref_info["sha"] = sha
147
+ is_first = !ref_info.key?("accessed")
148
+ ref_info["accessed"] = @timestamp
149
+ @modified = true
150
+ is_first
151
+ end
152
+
153
+ ##
154
+ # @private
155
+ #
156
+ def update_ref!(ref)
157
+ ref_info = @data["refs"][ref] ||= {}
158
+ is_first = !ref_info.key?("updated")
159
+ ref_info["updated"] = @timestamp
160
+ @modified = true
161
+ is_first
162
+ end
163
+
164
+ ##
165
+ # @private
166
+ #
167
+ def delete_ref!(ref)
168
+ ref_data = @data["refs"].delete(ref)
169
+ @modified = true if ref_data
170
+ ref_data
171
+ end
172
+
173
+ ##
174
+ # @private
175
+ #
176
+ def access_source!(sha, path)
177
+ @data["accessed"] = @timestamp
178
+ source_info = @data["sources"][sha] ||= {}
179
+ path_info = source_info[path] ||= {}
180
+ is_first = !path_info.key?("accessed")
181
+ path_info["accessed"] = @timestamp
182
+ @modified = true
183
+ is_first
184
+ end
185
+
186
+ ##
187
+ # @private
188
+ #
189
+ def delete_source!(sha, path)
190
+ sha_data = @data["sources"][sha]
191
+ return nil if sha_data.nil?
192
+ source_data = sha_data.delete(path)
193
+ if source_data
194
+ @modified = true
195
+ @data["sources"].delete(sha) if sha_data.empty?
196
+ end
197
+ source_data
198
+ end
199
+ end
200
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ class GitCache
4
+ ##
5
+ # Version of the git_cache gem
6
+ # @return [String]
7
+ #
8
+ VERSION = "0.1.1"
9
+ end
data/lib/git_cache.rb CHANGED
@@ -1,8 +1,434 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "git_cache/error"
4
+ require "git_cache/repo_info"
5
+ require "git_cache/repo_lock"
6
+
7
+ ##
8
+ # This object provides cached access to remote git data. Given a remote
9
+ # repository, a path, and a commit, it makes the files available in the
10
+ # local filesystem. Access is cached, so repeated requests for the same
11
+ # commit and path in the same repo do not hit the remote repository again.
1
12
  #
2
- # This is a placeholder Ruby file for gem "git_cache".
3
- # It was generated on 2026-05-04 to reserve the gem name.
4
- # The actual gem is planned for release in the near future.
5
- # If this is a problem, or if the actual gem has not been
6
- # released in a timely manner, you can contact the owner at
7
- # dazuma@gmail.com
8
- #
13
+ class GitCache
14
+ ##
15
+ # Access a git cache.
16
+ #
17
+ # @param cache_dir [String] The path to the cache directory. Defaults to
18
+ # a specific directory in the user's XDG cache.
19
+ #
20
+ def initialize(cache_dir: nil)
21
+ require "digest"
22
+ require "fileutils"
23
+ require "json"
24
+ require "exec_service"
25
+ @cache_dir = ::File.expand_path(cache_dir || default_cache_dir)
26
+ @exec = ::ExecService.new(out: :capture, err: :capture)
27
+ end
28
+
29
+ ##
30
+ # The cache directory.
31
+ #
32
+ # @return [String]
33
+ #
34
+ attr_reader :cache_dir
35
+
36
+ ##
37
+ # Get the given git-based files from the git cache, loading from the
38
+ # remote repo if necessary.
39
+ #
40
+ # The resulting files are either copied into a directory you provide in
41
+ # the `:into` parameter, or populated into a _shared_ source directory if
42
+ # you omit the `:into` parameter. In the latter case, it is important
43
+ # that you do not modify the returned files or directories, nor add or
44
+ # remove any files from the directories returned, to avoid confusing
45
+ # callers that could be given the same directory. If you need to make any
46
+ # modifications to the returned files, use `:into` to provide your own
47
+ # private directory.
48
+ #
49
+ # @param remote [String] The URL of the git repo. Required.
50
+ # @param path [String] The path to the file or directory within the repo.
51
+ # Optional. Defaults to the entire repo.
52
+ # @param commit [String] The commit reference, which may be a SHA or any
53
+ # git ref such as a branch or tag. Optional. Defaults to `HEAD`.
54
+ # @param into [String] If provided, copies the specified files into the
55
+ # given directory path. If omitted or `nil`, populates and returns a
56
+ # shared source file or directory.
57
+ # @param update [boolean,Integer] Whether to update non-SHA commit
58
+ # references if they were previously loaded. This is useful, for
59
+ # example, if the commit is `HEAD` or a branch name. Pass `true` or
60
+ # `false` to specify whether to update, or an integer to update if
61
+ # last update was done at least that many seconds ago. Default is
62
+ # `false`.
63
+ # @param timestamp [Integer,nil] The timestamp for recording the access
64
+ # time and determining whether a resource is stale. Normally, you
65
+ # should leave this out and it will default to the current time.
66
+ #
67
+ # @return [String] The full path to the cached files. The returned path
68
+ # will correspond to the path given. For example, if you provide the
69
+ # path `Gemfile` representing a single file in the repository, the
70
+ # returned path will point directly to the cached copy of that file.
71
+ #
72
+ def get(remote, path: nil, commit: nil, into: nil, update: false, timestamp: nil)
73
+ path = ::GitCache.normalize_path(path)
74
+ commit ||= "HEAD"
75
+ timestamp ||= ::Time.now.to_i
76
+ dir = ensure_repo_base_dir(remote)
77
+ lock_repo(dir, remote, timestamp) do |repo_lock|
78
+ ensure_repo(dir, remote)
79
+ sha = ensure_commit(dir, commit, repo_lock, update)
80
+ if into
81
+ copy_files(dir, sha, path, repo_lock, into)
82
+ else
83
+ ensure_source(dir, sha, path, repo_lock)
84
+ end
85
+ end
86
+ end
87
+ alias find get
88
+
89
+ ##
90
+ # Returns an array of the known remote names.
91
+ #
92
+ # @return [Array<String>]
93
+ #
94
+ def remotes
95
+ result = []
96
+ return result unless ::File.directory?(cache_dir)
97
+ ::Dir.entries(cache_dir).each do |child|
98
+ next if child.start_with?(".")
99
+ dir = ::File.join(cache_dir, child)
100
+ if ::File.file?(::File.join(dir, LOCK_FILE_NAME))
101
+ remote = lock_repo(dir, &:remote)
102
+ result << remote if remote
103
+ end
104
+ end
105
+ result.sort
106
+ end
107
+
108
+ ##
109
+ # Returns a {RepoInfo} describing the cache for the given remote, or
110
+ # `nil` if the given remote has never been cached.
111
+ #
112
+ # @param remote [String] Remote name for a repo
113
+ # @return [RepoInfo,nil]
114
+ #
115
+ def repo_info(remote)
116
+ dir = repo_base_dir_for(remote)
117
+ return nil unless ::File.directory?(dir)
118
+ lock_repo(dir, remote) do |repo_lock|
119
+ RepoInfo.new(dir, repo_lock.data)
120
+ end
121
+ end
122
+
123
+ ##
124
+ # Removes caches for the given repos, or all repos if specified.
125
+ #
126
+ # Removes all cache information for the specified repositories, including
127
+ # local clones and shared source directories. The next time these
128
+ # repositories are requested, they will be reloaded from the remote
129
+ # repository from scratch.
130
+ #
131
+ # Be careful not to remove repos that are currently in use by other
132
+ # GitCache clients.
133
+ #
134
+ # @param remotes [Array<String>,:all,nil] The remotes to remove. If set
135
+ # to :all or nil, removes all repos.
136
+ # @return [Array<String>] The remotes actually removed.
137
+ #
138
+ def remove_repos(remotes)
139
+ remotes = self.remotes if remotes.nil? || remotes == :all
140
+ Array(remotes).map do |remote|
141
+ dir = repo_base_dir_for(remote)
142
+ if ::File.directory?(dir)
143
+ ::FileUtils.chmod_R("u+w", dir, force: true)
144
+ ::FileUtils.rm_rf(dir)
145
+ remote
146
+ end
147
+ end.compact.sort
148
+ end
149
+
150
+ ##
151
+ # Remove records of the given refs (i.e. branches, tags, or `HEAD`) from
152
+ # the given repository's cache. The next time those refs are requested,
153
+ # they will be pulled from the remote repo.
154
+ #
155
+ # If you provide the `refs:` argument, only those refs are removed.
156
+ # Otherwise, all refs are removed.
157
+ #
158
+ # @param remote [String] The repository
159
+ # @param refs [Array<String>] The refs to remove. Optional.
160
+ # @return [Array<RefInfo>,nil] The refs actually forgotten, or `nil` if
161
+ # the given repo is not in the cache.
162
+ #
163
+ def remove_refs(remote, refs: nil)
164
+ dir = repo_base_dir_for(remote)
165
+ return nil unless ::File.directory?(dir)
166
+ results = []
167
+ lock_repo(dir, remote) do |repo_lock|
168
+ refs = repo_lock.refs if refs.nil? || refs == :all
169
+ Array(refs).each do |ref|
170
+ ref_data = repo_lock.delete_ref!(ref)
171
+ results << RefInfo.new(ref, ref_data) if ref_data
172
+ end
173
+ end
174
+ results.sort
175
+ end
176
+
177
+ ##
178
+ # Removes shared sources for the given cache. The next time a client
179
+ # requests them, the removed sources will be recopied from the repo.
180
+ #
181
+ # If you provide the `commits:` argument, only sources associated with
182
+ # those commits are removed. Otherwise, all sources are removed.
183
+ #
184
+ # Be careful not to remove sources that are currently in use by other
185
+ # GitCache clients.
186
+ #
187
+ # @param remote [String] The repository
188
+ # @param commits [Array<String>] Remove only the sources for the given
189
+ # commits. Optional.
190
+ # @return [Array<SourceInfo>,nil] The sources actually removed, or `nil`
191
+ # if the given repo is not in the cache.
192
+ #
193
+ def remove_sources(remote, commits: nil)
194
+ dir = repo_base_dir_for(remote)
195
+ return nil unless ::File.directory?(dir)
196
+ results = []
197
+ lock_repo(dir, remote) do |repo_lock|
198
+ commits = nil if commits == :all
199
+ shas = Array(commits).map { |ref| repo_lock.lookup_ref(ref) }.compact.uniq if commits
200
+ repo_lock.find_sources(shas: shas).each do |(sha, path)|
201
+ data = repo_lock.delete_source!(sha, path)
202
+ results << SourceInfo.new(dir, sha, path, data)
203
+ end
204
+ results.map(&:sha).uniq.each do |sha|
205
+ unless repo_lock.source_exists?(sha)
206
+ sha_dir = ::File.join(dir, sha)
207
+ ::FileUtils.chmod_R("u+w", sha_dir, force: true)
208
+ ::FileUtils.rm_rf(sha_dir)
209
+ end
210
+ end
211
+ end
212
+ results.sort
213
+ end
214
+
215
+ private
216
+
217
+ FORMAT_VERSION = "v1"
218
+ REPO_DIR_NAME = "repo"
219
+ LOCK_FILE_NAME = "repo.lock"
220
+ private_constant :REPO_DIR_NAME, :LOCK_FILE_NAME, :FORMAT_VERSION
221
+
222
+ def repo_base_dir_for(remote)
223
+ ::File.join(@cache_dir, ::GitCache.remote_dir_name(remote))
224
+ end
225
+
226
+ def default_cache_dir
227
+ require "simple_xdg"
228
+ ::File.join(::SimpleXDG.new.cache_home, "git-cache", FORMAT_VERSION)
229
+ end
230
+
231
+ def git(dir, cmd, error_message: nil)
232
+ result = @exec.exec(["git"] + cmd, chdir: dir)
233
+ if !result.success? && error_message
234
+ raise ::GitCache::Error.new(error_message, result)
235
+ end
236
+ result
237
+ end
238
+
239
+ def ensure_repo_base_dir(remote)
240
+ dir = repo_base_dir_for(remote)
241
+ ::FileUtils.mkdir_p(dir)
242
+ dir
243
+ end
244
+
245
+ def lock_repo(dir, remote = nil, timestamp = nil)
246
+ lock_path = ::File.join(dir, LOCK_FILE_NAME)
247
+ ::File.open(lock_path, ::File::RDWR | ::File::CREAT) do |file|
248
+ file.flock(::File::LOCK_EX)
249
+ file.rewind
250
+ repo_lock = RepoLock.new(file, remote, timestamp)
251
+ begin
252
+ yield repo_lock
253
+ ensure
254
+ if repo_lock.modified?
255
+ file.rewind
256
+ file.truncate(0)
257
+ repo_lock.dump(file)
258
+ end
259
+ end
260
+ end
261
+ end
262
+
263
+ def ensure_repo(dir, remote)
264
+ repo_dir = ::File.join(dir, REPO_DIR_NAME)
265
+ ::FileUtils.mkdir_p(repo_dir)
266
+ result = git(repo_dir, ["remote", "get-url", "origin"])
267
+ unless result.success? && result.captured_out.strip == remote
268
+ ::FileUtils.chmod_R("u+w", repo_dir, force: true)
269
+ ::FileUtils.rm_rf(repo_dir)
270
+ ::FileUtils.mkdir_p(repo_dir)
271
+ git(repo_dir, ["init"],
272
+ error_message: "Unable to initialize git repository")
273
+ git(repo_dir, ["remote", "add", "origin", remote],
274
+ error_message: "Unable to add git remote: #{remote}")
275
+ end
276
+ end
277
+
278
+ def ensure_commit(dir, commit, repo_lock, update = false)
279
+ local_commit = "git-cache/#{commit}"
280
+ repo_dir = ::File.join(dir, REPO_DIR_NAME)
281
+ is_sha = ::GitCache.valid_sha?(commit)
282
+ update = repo_lock.ref_stale?(commit, update) unless is_sha
283
+ if (update && !is_sha) || !commit_exists?(repo_dir, local_commit)
284
+ git(repo_dir, ["fetch", "--depth=1", "--force", "origin", "#{commit}:#{local_commit}"],
285
+ error_message: "Unable to fetch commit: #{commit}")
286
+ repo_lock.update_ref!(commit)
287
+ end
288
+ result = git(repo_dir, ["rev-parse", local_commit],
289
+ error_message: "Unable to retrieve commit: #{local_commit}")
290
+ sha = result.captured_out.strip
291
+ repo_lock.access_ref!(commit, sha)
292
+ sha
293
+ end
294
+
295
+ def commit_exists?(repo_dir, commit)
296
+ result = git(repo_dir, ["cat-file", "-t", commit])
297
+ result.success? && result.captured_out.strip == "commit"
298
+ end
299
+
300
+ def ensure_source(dir, sha, path, repo_lock)
301
+ repo_path = ::File.join(dir, REPO_DIR_NAME)
302
+ source_path = ::File.join(dir, sha)
303
+ result =
304
+ if repo_lock.source_exists?(sha, path)
305
+ ::GitCache.safe_join(source_path, path)
306
+ else
307
+ ::FileUtils.chmod_R("u+w", source_path, force: true)
308
+ begin
309
+ copy_from_repo(repo_path, source_path, sha, path)
310
+ ensure
311
+ ::FileUtils.chmod_R("a-w", source_path, force: true) unless ::GitCache.sources_writable?
312
+ end
313
+ end
314
+ repo_lock.access_source!(sha, path)
315
+ result
316
+ end
317
+
318
+ def copy_files(dir, sha, path, repo_lock, into)
319
+ repo_path = ::File.join(dir, REPO_DIR_NAME)
320
+ result = copy_from_repo(repo_path, into, sha, path)
321
+ repo_lock.access_repo!
322
+ result
323
+ end
324
+
325
+ def copy_from_repo(repo_dir, into, sha, path)
326
+ git(repo_dir, ["switch", "--detach", sha],
327
+ error_message: "Unable to switch to SHA #{sha}")
328
+ repo_path = ::GitCache.safe_join(repo_dir, path)
329
+ unless ::File.exist?(repo_path)
330
+ raise Error, "Path #{path.inspect} does not exist at SHA #{sha}"
331
+ end
332
+ into_path = ::GitCache.safe_join(into, path)
333
+ if path == "."
334
+ ::FileUtils.mkdir_p(into)
335
+ else
336
+ ::FileUtils.mkdir_p(::File.dirname(into_path))
337
+ end
338
+ copy_recursive(repo_path, into_path, is_root: path == ".")
339
+ into_path
340
+ end
341
+
342
+ def copy_recursive(from_path, to_path, is_root: false)
343
+ from_stat = safe_stat(from_path)
344
+ to_stat = safe_stat(to_path)
345
+ if to_stat && from_stat
346
+ if from_stat.directory? && to_stat.directory?
347
+ ::Dir.children(from_path).each do |child|
348
+ next if child == ".git" && is_root
349
+ copy_recursive(::File.join(from_path, child), ::File.join(to_path, child))
350
+ end
351
+ else
352
+ ::FileUtils.rm_rf(to_path)
353
+ ::FileUtils.copy_entry(from_path, to_path)
354
+ end
355
+ elsif to_stat
356
+ ::FileUtils.rm_rf(to_path)
357
+ elsif from_stat
358
+ ::FileUtils.copy_entry(from_path, to_path)
359
+ end
360
+ end
361
+
362
+ def safe_stat(path)
363
+ ::File.lstat(path)
364
+ rescue ::SystemCallError
365
+ nil
366
+ end
367
+
368
+ class << self
369
+ ##
370
+ # @private
371
+ # Returns whether shared source files are writable by default.
372
+ # Normally, shared sources are made read-only to protect them from being
373
+ # modified accidentally since multiple clients may be accessing them.
374
+ # However, you can disable this feature by setting the environment
375
+ # variable `GIT_CACHE_WRITABLE` to any non-empty value. This can be
376
+ # useful in environments that want to clean up temporary directories and
377
+ # are being hindered by read-only files.
378
+ #
379
+ # @return [boolean]
380
+ #
381
+ def sources_writable?
382
+ !::ENV["GIT_CACHE_WRITABLE"].to_s.empty?
383
+ end
384
+
385
+ ##
386
+ # @private
387
+ # Whether a given ref is a valid SHA-1 or SHA-256
388
+ #
389
+ # @param ref [String]
390
+ # @return [boolean]
391
+ #
392
+ def valid_sha?(ref)
393
+ /^[0-9a-f]+$/.match?(ref) && [40, 64].include?(ref.size)
394
+ end
395
+
396
+ ##
397
+ # @private
398
+ # Adds a path element to an existing path, handling the case where the
399
+ # new path element is ".".
400
+ #
401
+ # @param dir [String]
402
+ # @param path [String]
403
+ # @return [String]
404
+ #
405
+ def safe_join(dir, path)
406
+ path == "." ? dir : ::File.join(dir, path)
407
+ end
408
+
409
+ ##
410
+ # @private
411
+ #
412
+ def remote_dir_name(remote)
413
+ ::Digest::MD5.hexdigest(remote)
414
+ end
415
+
416
+ ##
417
+ # @private
418
+ #
419
+ def normalize_path(orig_path)
420
+ segs = []
421
+ orig_segs = orig_path.to_s.sub(%r{^/+}, "").split(%r{/+})
422
+ orig_segs.each do |seg|
423
+ if seg == ".."
424
+ raise ::ArgumentError, "Path #{orig_path.inspect} references its parent" if segs.empty?
425
+ segs.pop
426
+ elsif seg != "."
427
+ segs.push(seg)
428
+ end
429
+ end
430
+ raise ::ArgumentError, "Path #{orig_path.inspect} reads .git directory" if segs.first == ".git"
431
+ segs.empty? ? "." : segs.join("/")
432
+ end
433
+ end
434
+ end
metadata CHANGED
@@ -1,26 +1,69 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: git_cache
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
- - dazuma@gmail.com
7
+ - Daniel Azuma
8
8
  bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
- dependencies: []
12
- description: This is a placeholder gem, which was generated on 2026-05-04 to reserve
13
- the gem git_cache. The actual gem is planned for release in the near future. If
14
- this is a problem, or if the actual gem has not been released in a timely manner,
15
- you can contact the owner at dazuma@gmail.com.
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: exec_service
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: simple_xdg
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '0.1'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '0.1'
40
+ description: The GitCache class provides cached access to remote git data. Given a
41
+ remote repository, a path, and a commit, it makes the files from that repository
42
+ available in the local file system. Access is cached, so repeated requests for the
43
+ same commit and path in the same repo do not make additional network calls.
44
+ email:
45
+ - dazuma@gmail.com
16
46
  executables: []
17
47
  extensions: []
18
48
  extra_rdoc_files: []
19
49
  files:
50
+ - ".yardopts"
51
+ - CHANGELOG.md
52
+ - LICENSE.md
20
53
  - README.md
21
54
  - lib/git_cache.rb
22
- licenses: []
23
- metadata: {}
55
+ - lib/git_cache/error.rb
56
+ - lib/git_cache/repo_info.rb
57
+ - lib/git_cache/repo_lock.rb
58
+ - lib/git_cache/version.rb
59
+ homepage: https://github.com/dazuma/git_cache
60
+ licenses:
61
+ - MIT
62
+ metadata:
63
+ bug_tracker_uri: https://github.com/dazuma/git_cache/issues
64
+ changelog_uri: https://rubydoc.info/gems/git_cache/0.1.1/file/CHANGELOG.md
65
+ documentation_uri: https://rubydoc.info/gems/git_cache/0.1.1
66
+ homepage_uri: https://github.com/dazuma/git_cache
24
67
  rdoc_options: []
25
68
  require_paths:
26
69
  - lib
@@ -28,7 +71,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
28
71
  requirements:
29
72
  - - ">="
30
73
  - !ruby/object:Gem::Version
31
- version: '0'
74
+ version: '2.7'
32
75
  required_rubygems_version: !ruby/object:Gem::Requirement
33
76
  requirements:
34
77
  - - ">="
@@ -37,5 +80,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
37
80
  requirements: []
38
81
  rubygems_version: 4.0.6
39
82
  specification_version: 4
40
- summary: Placeholder gem
83
+ summary: A local file system cache of data from git repositories.
41
84
  test_files: []