git_cache 0.1.1 → 0.1.2

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: d3b7101169665dc9115fad2454928336b6ef83cca3b1db38a8ae6acef81062b1
4
- data.tar.gz: f58df6572f0e3511ef30e4e7f1b0843e54ee59b157edb77d84f268b8a4bc0135
3
+ metadata.gz: 1c5d6cf239e6dd1cd7ba89cc4721ddc05dc5a1c1ff2d0c6b7fded9b06bac5eae
4
+ data.tar.gz: b435524513c83ce7fdca4aed22351cb29fda9da8435f6a7af94c591b87791f5b
5
5
  SHA512:
6
- metadata.gz: 0be87372ec841eb2288330508ce7bd5aaa61cb7f1332b78ff4866f63c071534204309dfb2bc4417bb47f093901dc22063cf1ba5c84d9f0f0391addf660fa0b91
7
- data.tar.gz: 45fa8853c29cc760cd59f3054a39d43bf13d72b5fc9f2afcb4cdf5460a3b30b96a212754d6c878d08d418986cf2858a1108c1c68e5b191b3a437ed76e0d775b7
6
+ metadata.gz: 7d84f21b22047b18c1d1734d31b84454555d3a5abc8ae7d63ea8b6a9a443393fc0c8cd2bfaf2c8935cd514e3771d999b873ac360a21a107501a7515d44dc7935
7
+ data.tar.gz: 135e7c7031ffa4bf11b2d3b3ace3179c0b4bdaa4b3d67d1e5e1d43b47add706194db510b0bb504a501f873b200f31128d8a4c591f69c9f00f26dd7514f63790d
data/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Release History
2
2
 
3
+ ### v0.1.2 / 2026-08-20
4
+
5
+ * FIXED: Prevent git auto maintenance from racing with cache removals
6
+
3
7
  ### v0.1.1 / 2026-05-05
4
8
 
5
9
  * BREAKING CHANGE: Make GitCache.sources_writable? private for now
@@ -9,11 +9,11 @@ class GitCache
9
9
  # Create a GitCache::Error.
10
10
  #
11
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.
12
+ # @param result [::ExecService::Result,nil] The result of a git
13
+ # command execution. Omit or pass `nil` if this error was not due to
14
+ # a git command error.
15
15
  #
16
- def initialize(message, result)
16
+ def initialize(message, result = nil)
17
17
  super(message)
18
18
  @exec_result = result
19
19
  end
@@ -5,5 +5,5 @@ class GitCache
5
5
  # Version of the git_cache gem
6
6
  # @return [String]
7
7
  #
8
- VERSION = "0.1.1"
8
+ VERSION = "0.1.2"
9
9
  end
data/lib/git_cache.rb CHANGED
@@ -21,6 +21,7 @@ class GitCache
21
21
  require "digest"
22
22
  require "fileutils"
23
23
  require "json"
24
+ require "securerandom"
24
25
  require "exec_service"
25
26
  @cache_dir = ::File.expand_path(cache_dir || default_cache_dir)
26
27
  @exec = ::ExecService.new(out: :capture, err: :capture)
@@ -140,8 +141,7 @@ class GitCache
140
141
  Array(remotes).map do |remote|
141
142
  dir = repo_base_dir_for(remote)
142
143
  if ::File.directory?(dir)
143
- ::FileUtils.chmod_R("u+w", dir, force: true)
144
- ::FileUtils.rm_rf(dir)
144
+ remove_dir(dir)
145
145
  remote
146
146
  end
147
147
  end.compact.sort
@@ -203,9 +203,7 @@ class GitCache
203
203
  end
204
204
  results.map(&:sha).uniq.each do |sha|
205
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)
206
+ remove_dir(::File.join(dir, sha))
209
207
  end
210
208
  end
211
209
  end
@@ -217,7 +215,19 @@ class GitCache
217
215
  FORMAT_VERSION = "v1"
218
216
  REPO_DIR_NAME = "repo"
219
217
  LOCK_FILE_NAME = "repo.lock"
220
- private_constant :REPO_DIR_NAME, :LOCK_FILE_NAME, :FORMAT_VERSION
218
+ TRASH_DIR_PREFIX = ".trash-"
219
+
220
+ # Config applied to every git invocation. Auto maintenance would otherwise
221
+ # spawn a detached `git maintenance run --auto` process after each fetch,
222
+ # which keeps writing into the cache repo after the fetch has returned, and
223
+ # thus races with our own traversals and removals of that directory. These
224
+ # are managed cache repos that we recreate at will, so background repacking
225
+ # buys them nothing. Requires git 2.30 or later; older gits ignore config
226
+ # keys they do not recognize.
227
+ GIT_CONFIG_ARGS = ["-c", "maintenance.auto=false"].freeze
228
+
229
+ private_constant :REPO_DIR_NAME, :LOCK_FILE_NAME, :FORMAT_VERSION,
230
+ :TRASH_DIR_PREFIX, :GIT_CONFIG_ARGS
221
231
 
222
232
  def repo_base_dir_for(remote)
223
233
  ::File.join(@cache_dir, ::GitCache.remote_dir_name(remote))
@@ -229,13 +239,72 @@ class GitCache
229
239
  end
230
240
 
231
241
  def git(dir, cmd, error_message: nil)
232
- result = @exec.exec(["git"] + cmd, chdir: dir)
242
+ result = @exec.exec(["git"] + GIT_CONFIG_ARGS + cmd, chdir: dir)
233
243
  if !result.success? && error_message
234
244
  raise ::GitCache::Error.new(error_message, result)
235
245
  end
236
246
  result
237
247
  end
238
248
 
249
+ # Removes a directory from the cache.
250
+ #
251
+ # The directory is first renamed out of the way, which is atomic and thus
252
+ # unaffected by any concurrent writes happening inside it, so the cache
253
+ # entry is gone as far as any client is concerned as soon as this returns.
254
+ # Only then is the renamed copy deleted, best effort. Anything left behind
255
+ # is invisible to clients and is swept up by later removals.
256
+ #
257
+ # Raises {GitCache::Error} if the directory could not be removed at all.
258
+ #
259
+ def remove_dir(dir)
260
+ return unless ::File.exist?(dir)
261
+ parent = ::File.dirname(dir)
262
+ begin
263
+ ::File.rename(dir, ::File.join(parent, "#{TRASH_DIR_PREFIX}#{::SecureRandom.hex(8)}"))
264
+ rescue ::SystemCallError
265
+ # Some filesystems (notably on Windows) refuse to rename a directory
266
+ # that has open handles under it. Fall back to deleting in place.
267
+ unless remove_dir_in_place(dir)
268
+ raise ::GitCache::Error, "Unable to remove directory: #{dir}"
269
+ end
270
+ end
271
+ sweep_trash(parent)
272
+ end
273
+
274
+ # Deletes a directory in place, retrying because a concurrent writer can
275
+ # defeat a single pass. Returns whether the directory is gone.
276
+ #
277
+ def remove_dir_in_place(dir, attempts: 3)
278
+ attempts.times do
279
+ chmod_recursive("u+w", dir)
280
+ ::FileUtils.rm_rf(dir)
281
+ return true unless ::File.exist?(dir)
282
+ end
283
+ false
284
+ end
285
+
286
+ # Recursive chmod that tolerates entries disappearing while it runs. The
287
+ # force: option of FileUtils.chmod_R covers only the chmod of each entry,
288
+ # not the traversal that finds them, so a concurrent writer removing a
289
+ # directory mid-walk raises out of chmod_R despite it.
290
+ #
291
+ def chmod_recursive(mode, dir)
292
+ ::FileUtils.chmod_R(mode, dir, force: true)
293
+ rescue ::SystemCallError
294
+ nil
295
+ end
296
+
297
+ # Deletes any leftover trash directories in the given directory. Failures
298
+ # are ignored; the leftovers are harmless and we can try again next time.
299
+ #
300
+ def sweep_trash(dir)
301
+ ::Dir.children(dir).each do |child|
302
+ remove_dir_in_place(::File.join(dir, child)) if child.start_with?(TRASH_DIR_PREFIX)
303
+ end
304
+ rescue ::SystemCallError
305
+ nil
306
+ end
307
+
239
308
  def ensure_repo_base_dir(remote)
240
309
  dir = repo_base_dir_for(remote)
241
310
  ::FileUtils.mkdir_p(dir)
@@ -265,8 +334,7 @@ class GitCache
265
334
  ::FileUtils.mkdir_p(repo_dir)
266
335
  result = git(repo_dir, ["remote", "get-url", "origin"])
267
336
  unless result.success? && result.captured_out.strip == remote
268
- ::FileUtils.chmod_R("u+w", repo_dir, force: true)
269
- ::FileUtils.rm_rf(repo_dir)
337
+ remove_dir(repo_dir)
270
338
  ::FileUtils.mkdir_p(repo_dir)
271
339
  git(repo_dir, ["init"],
272
340
  error_message: "Unable to initialize git repository")
@@ -304,11 +372,11 @@ class GitCache
304
372
  if repo_lock.source_exists?(sha, path)
305
373
  ::GitCache.safe_join(source_path, path)
306
374
  else
307
- ::FileUtils.chmod_R("u+w", source_path, force: true)
375
+ chmod_recursive("u+w", source_path)
308
376
  begin
309
377
  copy_from_repo(repo_path, source_path, sha, path)
310
378
  ensure
311
- ::FileUtils.chmod_R("a-w", source_path, force: true) unless ::GitCache.sources_writable?
379
+ chmod_recursive("a-w", source_path) unless ::GitCache.sources_writable?
312
380
  end
313
381
  end
314
382
  repo_lock.access_source!(sha, path)
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.1
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Daniel Azuma
@@ -61,8 +61,8 @@ 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.1/file/CHANGELOG.md
65
- documentation_uri: https://rubydoc.info/gems/git_cache/0.1.1
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
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.6
81
+ rubygems_version: 4.0.16
82
82
  specification_version: 4
83
83
  summary: A local file system cache of data from git repositories.
84
84
  test_files: []