git_cache 0.1.2 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1c5d6cf239e6dd1cd7ba89cc4721ddc05dc5a1c1ff2d0c6b7fded9b06bac5eae
4
- data.tar.gz: b435524513c83ce7fdca4aed22351cb29fda9da8435f6a7af94c591b87791f5b
3
+ metadata.gz: bf5b975bb07d2180af37c7d22c0823d9266adb7993485bbb4f5708ab3b769421
4
+ data.tar.gz: d2ff0b8e4b2e41ad1f19e36b81bab3ba6a9b08483a0495a1d270065f6e15473f
5
5
  SHA512:
6
- metadata.gz: 7d84f21b22047b18c1d1734d31b84454555d3a5abc8ae7d63ea8b6a9a443393fc0c8cd2bfaf2c8935cd514e3771d999b873ac360a21a107501a7515d44dc7935
7
- data.tar.gz: 135e7c7031ffa4bf11b2d3b3ace3179c0b4bdaa4b3d67d1e5e1d43b47add706194db510b0bb504a501f873b200f31128d8a4c591f69c9f00f26dd7514f63790d
6
+ metadata.gz: 90401416dea171255778bb4372c3af86f0a7235195db3de596f5fdf60542e23538685a27dcaa6c82b3583f187b7f0608ada0b3e63978aefdc3dccfd4bbf03132
7
+ data.tar.gz: 2d58c482b5d55949e9eab5a06ef789aaa184a760c80185b78680562448e7eaf913b8a4e317374b0c0b1278777965a5e0aa106a4c65ae18b6d8cad81975353e6d
data/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Release History
2
2
 
3
+ ### v0.2.0 / 2026-10-06
4
+
5
+ Note: This release changes the internal cache format from v1 to v2. This release will start with a new empty cache, but will not interfere with older versions and older caches.
6
+
7
+ * FIXED: Raise GitCache::Error when the cache directory cannot be created
8
+ * FIXED: Move repo locks out of the directories they protect
9
+ * FIXED: Store cache data in a format version subdirectory of custom cache dirs
10
+ * FIXED: Write repo state atomically
11
+ * FIXED: Record the remote even if the first get fails
12
+ * FIXED: Do not mask a failed operation's error with a state write error
13
+
3
14
  ### v0.1.2 / 2026-08-20
4
15
 
5
16
  * FIXED: Prevent git auto maintenance from racing with cache removals
@@ -2,9 +2,10 @@
2
2
 
3
3
  class GitCache
4
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.
5
+ # Associated with each repo (remote) is a state file, `state.json` in the
6
+ # repo's base dir, that saves the status of the cache. It is read and
7
+ # written only while holding the repo's lock, which is a separate file
8
+ # outside the base dir. This is handled by the lock_repo method.
8
9
  #
9
10
  # This object represents the state of the repo, and is made available to
10
11
  # the block passed to lock_repo. It has the following schema:
@@ -23,16 +24,18 @@ class GitCache
23
24
  #
24
25
  # @private
25
26
  #
26
- class RepoLock
27
+ class RepoState
27
28
  ##
28
29
  # @private
29
30
  #
30
- def initialize(io, remote, timestamp)
31
- @data = ::JSON.parse(io.read) rescue {} # rubocop:disable Style/RescueModifier
31
+ def initialize(json, remote, timestamp)
32
+ @data = ::JSON.parse(json) rescue {} # rubocop:disable Style/RescueModifier
33
+ # Record the remote if the state lacks it (e.g. a new repo), so the
34
+ # repo is listed by GitCache#remotes even if nothing else is recorded.
35
+ @modified = @data["remote"].nil? && !remote.nil?
32
36
  @data["remote"] ||= remote
33
37
  @data["refs"] ||= {}
34
38
  @data["sources"] ||= {}
35
- @modified = false
36
39
  @timestamp = timestamp || ::Time.now.to_i
37
40
  end
38
41
 
@@ -51,8 +54,8 @@ class GitCache
51
54
  ##
52
55
  # @private
53
56
  #
54
- def dump(io)
55
- ::JSON.dump(@data, io)
57
+ def dump
58
+ ::JSON.dump(@data)
56
59
  end
57
60
 
58
61
  ##
@@ -5,5 +5,5 @@ class GitCache
5
5
  # Version of the git_cache gem
6
6
  # @return [String]
7
7
  #
8
- VERSION = "0.1.2"
8
+ VERSION = "0.2.0"
9
9
  end
data/lib/git_cache.rb CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  require "git_cache/error"
4
4
  require "git_cache/repo_info"
5
- require "git_cache/repo_lock"
5
+ require "git_cache/repo_state"
6
6
 
7
7
  ##
8
8
  # This object provides cached access to remote git data. Given a remote
@@ -15,7 +15,9 @@ class GitCache
15
15
  # Access a git cache.
16
16
  #
17
17
  # @param cache_dir [String] The path to the cache directory. Defaults to
18
- # a specific directory in the user's XDG cache.
18
+ # a specific directory in the user's XDG cache. Cache data is stored
19
+ # in a subdirectory named for the cache format version, so that clients
20
+ # using incompatible formats can share a cache directory safely.
19
21
  #
20
22
  def initialize(cache_dir: nil)
21
23
  require "digest"
@@ -23,7 +25,9 @@ class GitCache
23
25
  require "json"
24
26
  require "securerandom"
25
27
  require "exec_service"
28
+ @using_default_cache_dir = cache_dir.nil?
26
29
  @cache_dir = ::File.expand_path(cache_dir || default_cache_dir)
30
+ @data_dir = ::File.join(@cache_dir, FORMAT_VERSION)
27
31
  @exec = ::ExecService.new(out: :capture, err: :capture)
28
32
  end
29
33
 
@@ -74,14 +78,15 @@ class GitCache
74
78
  path = ::GitCache.normalize_path(path)
75
79
  commit ||= "HEAD"
76
80
  timestamp ||= ::Time.now.to_i
77
- dir = ensure_repo_base_dir(remote)
78
- lock_repo(dir, remote, timestamp) do |repo_lock|
81
+ name = ::GitCache.remote_dir_name(remote)
82
+ dir = repo_base_dir_for(name)
83
+ lock_repo(name, remote, timestamp, create: true) do |repo_state|
79
84
  ensure_repo(dir, remote)
80
- sha = ensure_commit(dir, commit, repo_lock, update)
85
+ sha = ensure_commit(dir, commit, repo_state, update)
81
86
  if into
82
- copy_files(dir, sha, path, repo_lock, into)
87
+ copy_files(dir, sha, path, repo_state, into)
83
88
  else
84
- ensure_source(dir, sha, path, repo_lock)
89
+ ensure_source(dir, sha, path, repo_state)
85
90
  end
86
91
  end
87
92
  end
@@ -94,14 +99,13 @@ class GitCache
94
99
  #
95
100
  def remotes
96
101
  result = []
97
- return result unless ::File.directory?(cache_dir)
98
- ::Dir.entries(cache_dir).each do |child|
99
- next if child.start_with?(".")
100
- dir = ::File.join(cache_dir, child)
101
- if ::File.file?(::File.join(dir, LOCK_FILE_NAME))
102
- remote = lock_repo(dir, &:remote)
103
- result << remote if remote
104
- end
102
+ repos_dir = ::File.join(@data_dir, REPOS_DIR_NAME)
103
+ return result unless ::File.directory?(repos_dir)
104
+ ::Dir.children(repos_dir).each do |name|
105
+ next if name.start_with?(".")
106
+ next unless ::File.file?(::File.join(repos_dir, name, STATE_FILE_NAME))
107
+ remote = lock_repo(name, &:remote)
108
+ result << remote if remote
105
109
  end
106
110
  result.sort
107
111
  end
@@ -114,10 +118,11 @@ class GitCache
114
118
  # @return [RepoInfo,nil]
115
119
  #
116
120
  def repo_info(remote)
117
- dir = repo_base_dir_for(remote)
121
+ name = ::GitCache.remote_dir_name(remote)
122
+ dir = repo_base_dir_for(name)
118
123
  return nil unless ::File.directory?(dir)
119
- lock_repo(dir, remote) do |repo_lock|
120
- RepoInfo.new(dir, repo_lock.data)
124
+ lock_repo(name, remote) do |repo_state|
125
+ RepoInfo.new(dir, repo_state.data)
121
126
  end
122
127
  end
123
128
 
@@ -129,8 +134,9 @@ class GitCache
129
134
  # repositories are requested, they will be reloaded from the remote
130
135
  # repository from scratch.
131
136
  #
132
- # Be careful not to remove repos that are currently in use by other
133
- # GitCache clients.
137
+ # This waits for any in-progress {#get} calls for these repos to finish.
138
+ # However, be careful not to remove repos whose shared sources are
139
+ # currently in use by other GitCache clients.
134
140
  #
135
141
  # @param remotes [Array<String>,:all,nil] The remotes to remove. If set
136
142
  # to :all or nil, removes all repos.
@@ -139,8 +145,14 @@ class GitCache
139
145
  def remove_repos(remotes)
140
146
  remotes = self.remotes if remotes.nil? || remotes == :all
141
147
  Array(remotes).map do |remote|
142
- dir = repo_base_dir_for(remote)
143
- if ::File.directory?(dir)
148
+ name = ::GitCache.remote_dir_name(remote)
149
+ dir = repo_base_dir_for(name)
150
+ next unless ::File.directory?(dir)
151
+ # Take the lock so we wait for any in-flight operation on this repo.
152
+ # The lock file lives outside the directory being removed, so it keeps
153
+ # excluding later clients after the removal.
154
+ flock_repo(name) do
155
+ next unless ::File.directory?(dir)
144
156
  remove_dir(dir)
145
157
  remote
146
158
  end
@@ -161,17 +173,17 @@ class GitCache
161
173
  # the given repo is not in the cache.
162
174
  #
163
175
  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
176
+ name = ::GitCache.remote_dir_name(remote)
177
+ return nil unless ::File.directory?(repo_base_dir_for(name))
178
+ lock_repo(name, remote) do |repo_state|
179
+ results = []
180
+ refs = repo_state.refs if refs.nil? || refs == :all
169
181
  Array(refs).each do |ref|
170
- ref_data = repo_lock.delete_ref!(ref)
182
+ ref_data = repo_state.delete_ref!(ref)
171
183
  results << RefInfo.new(ref, ref_data) if ref_data
172
184
  end
185
+ results.sort
173
186
  end
174
- results.sort
175
187
  end
176
188
 
177
189
  ##
@@ -191,30 +203,49 @@ class GitCache
191
203
  # if the given repo is not in the cache.
192
204
  #
193
205
  def remove_sources(remote, commits: nil)
194
- dir = repo_base_dir_for(remote)
206
+ name = ::GitCache.remote_dir_name(remote)
207
+ dir = repo_base_dir_for(name)
195
208
  return nil unless ::File.directory?(dir)
196
- results = []
197
- lock_repo(dir, remote) do |repo_lock|
209
+ lock_repo(name, remote) do |repo_state|
210
+ results = []
198
211
  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)
212
+ shas = Array(commits).map { |ref| repo_state.lookup_ref(ref) }.compact.uniq if commits
213
+ repo_state.find_sources(shas: shas).each do |(sha, path)|
214
+ data = repo_state.delete_source!(sha, path)
202
215
  results << SourceInfo.new(dir, sha, path, data)
203
216
  end
204
217
  results.map(&:sha).uniq.each do |sha|
205
- unless repo_lock.source_exists?(sha)
218
+ unless repo_state.source_exists?(sha)
206
219
  remove_dir(::File.join(dir, sha))
207
220
  end
208
221
  end
222
+ results.sort
209
223
  end
210
- results.sort
211
224
  end
212
225
 
213
226
  private
214
227
 
215
- FORMAT_VERSION = "v1"
228
+ # Cache layout, relative to the cache directory:
229
+ #
230
+ # <FORMAT_VERSION>/ Data dir. Bumping FORMAT_VERSION on
231
+ # incompatible layout changes isolates clients
232
+ # using different formats.
233
+ # locks/<name>.lock Lock file for the repo. Empty; used only as a
234
+ # flock target. Never deleted (see flock_repo).
235
+ # repos/<name>/ Base dir for the repo. Removing it (via a
236
+ # rename) removes the repo from the cache.
237
+ # state.json Repo state (see RepoState).
238
+ # repo/ Working clone of the remote.
239
+ # <sha>/ Shared sources for a commit.
240
+ #
241
+ # where <name> is the remote_dir_name of the remote.
242
+ #
243
+ FORMAT_VERSION = "v2"
244
+ LOCKS_DIR_NAME = "locks"
245
+ REPOS_DIR_NAME = "repos"
246
+ LOCK_FILE_SUFFIX = ".lock"
247
+ STATE_FILE_NAME = "state.json"
216
248
  REPO_DIR_NAME = "repo"
217
- LOCK_FILE_NAME = "repo.lock"
218
249
  TRASH_DIR_PREFIX = ".trash-"
219
250
 
220
251
  # Config applied to every git invocation. Auto maintenance would otherwise
@@ -226,16 +257,23 @@ class GitCache
226
257
  # keys they do not recognize.
227
258
  GIT_CONFIG_ARGS = ["-c", "maintenance.auto=false"].freeze
228
259
 
229
- private_constant :REPO_DIR_NAME, :LOCK_FILE_NAME, :FORMAT_VERSION,
260
+ private_constant :FORMAT_VERSION, :LOCKS_DIR_NAME, :REPOS_DIR_NAME,
261
+ :LOCK_FILE_SUFFIX, :STATE_FILE_NAME, :REPO_DIR_NAME,
230
262
  :TRASH_DIR_PREFIX, :GIT_CONFIG_ARGS
231
263
 
232
- def repo_base_dir_for(remote)
233
- ::File.join(@cache_dir, ::GitCache.remote_dir_name(remote))
264
+ # Takes the remote_dir_name of a remote
265
+ def repo_base_dir_for(name)
266
+ ::File.join(@data_dir, REPOS_DIR_NAME, name)
267
+ end
268
+
269
+ # Takes the remote_dir_name of a remote
270
+ def repo_lock_path_for(name)
271
+ ::File.join(@data_dir, LOCKS_DIR_NAME, "#{name}#{LOCK_FILE_SUFFIX}")
234
272
  end
235
273
 
236
274
  def default_cache_dir
237
275
  require "simple_xdg"
238
- ::File.join(::SimpleXDG.new.cache_home, "git-cache", FORMAT_VERSION)
276
+ ::File.join(::SimpleXDG.new.cache_home, "git-cache")
239
277
  end
240
278
 
241
279
  def git(dir, cmd, error_message: nil)
@@ -305,30 +343,82 @@ class GitCache
305
343
  nil
306
344
  end
307
345
 
308
- def ensure_repo_base_dir(remote)
309
- dir = repo_base_dir_for(remote)
346
+ def ensure_cache_subdir(dir)
310
347
  ::FileUtils.mkdir_p(dir)
311
- dir
348
+ rescue ::SystemCallError => e
349
+ message = "Unable to create git cache directory #{dir}: #{e.message}"
350
+ message += ". Set XDG_CACHE_HOME to a writable directory." if @using_default_cache_dir
351
+ raise Error, message
312
352
  end
313
353
 
314
- def lock_repo(dir, remote = nil, timestamp = nil)
315
- lock_path = ::File.join(dir, LOCK_FILE_NAME)
354
+ # Takes an exclusive lock on the given repo for the duration of the block,
355
+ # and returns the value of the block. Takes the remote_dir_name of a remote.
356
+ #
357
+ # The lock file lives outside the repo's base dir, so that removing the
358
+ # base dir does not also remove the lock. Lock files must never be deleted:
359
+ # a flock belongs to an inode, so if the file were deleted, a newcomer
360
+ # would create a new one and "acquire" it while an older client still
361
+ # holds the lock on the old one.
362
+ #
363
+ def flock_repo(name)
364
+ lock_path = repo_lock_path_for(name)
365
+ ensure_cache_subdir(::File.dirname(lock_path))
316
366
  ::File.open(lock_path, ::File::RDWR | ::File::CREAT) do |file|
317
367
  file.flock(::File::LOCK_EX)
318
- file.rewind
319
- repo_lock = RepoLock.new(file, remote, timestamp)
368
+ yield
369
+ end
370
+ end
371
+
372
+ # Takes an exclusive lock on the given repo, and yields its state as a
373
+ # {RepoState}, writing the state back afterward if it was modified. Returns
374
+ # the value of the block. Takes the remote_dir_name of a remote.
375
+ #
376
+ # If create is true, creates the repo's base dir if it does not exist.
377
+ # Otherwise, if the base dir does not exist (e.g. because it was removed
378
+ # while we were waiting for the lock), returns nil without yielding.
379
+ #
380
+ def lock_repo(name, remote = nil, timestamp = nil, create: false)
381
+ flock_repo(name) do
382
+ dir = repo_base_dir_for(name)
383
+ if create
384
+ ensure_cache_subdir(dir)
385
+ elsif !::File.directory?(dir)
386
+ next nil
387
+ end
388
+ state_path = ::File.join(dir, STATE_FILE_NAME)
389
+ content = ::File.file?(state_path) ? ::File.read(state_path) : ""
390
+ repo_state = RepoState.new(content, remote, timestamp)
391
+ completed = false
320
392
  begin
321
- yield repo_lock
393
+ result = yield repo_state
394
+ completed = true
395
+ result
322
396
  ensure
323
- if repo_lock.modified?
324
- file.rewind
325
- file.truncate(0)
326
- repo_lock.dump(file)
397
+ if repo_state.modified?
398
+ begin
399
+ write_state(state_path, repo_state)
400
+ rescue ::StandardError
401
+ # If the block failed, let its error propagate rather than this
402
+ # one. The atomic write leaves the previous state intact.
403
+ raise if completed
404
+ end
327
405
  end
328
406
  end
329
407
  end
330
408
  end
331
409
 
410
+ # Writes the repo state to a temp file and renames it into place, so a
411
+ # failure partway through the write leaves the previous state intact. Must
412
+ # be called while holding the repo's lock.
413
+ #
414
+ def write_state(state_path, repo_state)
415
+ temp_path = "#{state_path}.tmp-#{::SecureRandom.hex(8)}"
416
+ ::File.write(temp_path, repo_state.dump)
417
+ ::File.rename(temp_path, state_path)
418
+ ensure
419
+ ::FileUtils.rm_f(temp_path)
420
+ end
421
+
332
422
  def ensure_repo(dir, remote)
333
423
  repo_dir = ::File.join(dir, REPO_DIR_NAME)
334
424
  ::FileUtils.mkdir_p(repo_dir)
@@ -343,20 +433,20 @@ class GitCache
343
433
  end
344
434
  end
345
435
 
346
- def ensure_commit(dir, commit, repo_lock, update = false)
436
+ def ensure_commit(dir, commit, repo_state, update = false)
347
437
  local_commit = "git-cache/#{commit}"
348
438
  repo_dir = ::File.join(dir, REPO_DIR_NAME)
349
439
  is_sha = ::GitCache.valid_sha?(commit)
350
- update = repo_lock.ref_stale?(commit, update) unless is_sha
440
+ update = repo_state.ref_stale?(commit, update) unless is_sha
351
441
  if (update && !is_sha) || !commit_exists?(repo_dir, local_commit)
352
442
  git(repo_dir, ["fetch", "--depth=1", "--force", "origin", "#{commit}:#{local_commit}"],
353
443
  error_message: "Unable to fetch commit: #{commit}")
354
- repo_lock.update_ref!(commit)
444
+ repo_state.update_ref!(commit)
355
445
  end
356
446
  result = git(repo_dir, ["rev-parse", local_commit],
357
447
  error_message: "Unable to retrieve commit: #{local_commit}")
358
448
  sha = result.captured_out.strip
359
- repo_lock.access_ref!(commit, sha)
449
+ repo_state.access_ref!(commit, sha)
360
450
  sha
361
451
  end
362
452
 
@@ -365,11 +455,11 @@ class GitCache
365
455
  result.success? && result.captured_out.strip == "commit"
366
456
  end
367
457
 
368
- def ensure_source(dir, sha, path, repo_lock)
458
+ def ensure_source(dir, sha, path, repo_state)
369
459
  repo_path = ::File.join(dir, REPO_DIR_NAME)
370
460
  source_path = ::File.join(dir, sha)
371
461
  result =
372
- if repo_lock.source_exists?(sha, path)
462
+ if repo_state.source_exists?(sha, path)
373
463
  ::GitCache.safe_join(source_path, path)
374
464
  else
375
465
  chmod_recursive("u+w", source_path)
@@ -379,14 +469,14 @@ class GitCache
379
469
  chmod_recursive("a-w", source_path) unless ::GitCache.sources_writable?
380
470
  end
381
471
  end
382
- repo_lock.access_source!(sha, path)
472
+ repo_state.access_source!(sha, path)
383
473
  result
384
474
  end
385
475
 
386
- def copy_files(dir, sha, path, repo_lock, into)
476
+ def copy_files(dir, sha, path, repo_state, into)
387
477
  repo_path = ::File.join(dir, REPO_DIR_NAME)
388
478
  result = copy_from_repo(repo_path, into, sha, path)
389
- repo_lock.access_repo!
479
+ repo_state.access_repo!
390
480
  result
391
481
  end
392
482
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: git_cache
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Daniel Azuma
@@ -54,15 +54,15 @@ files:
54
54
  - lib/git_cache.rb
55
55
  - lib/git_cache/error.rb
56
56
  - lib/git_cache/repo_info.rb
57
- - lib/git_cache/repo_lock.rb
57
+ - lib/git_cache/repo_state.rb
58
58
  - lib/git_cache/version.rb
59
59
  homepage: https://github.com/dazuma/git_cache
60
60
  licenses:
61
61
  - MIT
62
62
  metadata:
63
63
  bug_tracker_uri: https://github.com/dazuma/git_cache/issues
64
- changelog_uri: https://rubydoc.info/gems/git_cache/0.1.2/file/CHANGELOG.md
65
- documentation_uri: https://rubydoc.info/gems/git_cache/0.1.2
64
+ changelog_uri: https://rubydoc.info/gems/git_cache/0.2.0/file/CHANGELOG.md
65
+ documentation_uri: https://rubydoc.info/gems/git_cache/0.2.0
66
66
  homepage_uri: https://github.com/dazuma/git_cache
67
67
  rdoc_options: []
68
68
  require_paths:
@@ -78,7 +78,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
78
78
  - !ruby/object:Gem::Version
79
79
  version: '0'
80
80
  requirements: []
81
- rubygems_version: 4.0.16
81
+ rubygems_version: 4.0.20
82
82
  specification_version: 4
83
83
  summary: A local file system cache of data from git repositories.
84
84
  test_files: []