rails-wayback 0.1.0 → 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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +120 -7
  3. data/Rakefile +17 -1
  4. data/app/controllers/rails_wayback/assets_controller.rb +78 -0
  5. data/app/controllers/rails_wayback/pages_controller.rb +50 -9
  6. data/config/routes.rb +5 -0
  7. data/lib/generators/rails_wayback/install/install_generator.rb +5 -3
  8. data/lib/generators/rails_wayback/install/templates/initializer.rb.tt +18 -1
  9. data/lib/rails_wayback/asset_provenance.rb +41 -0
  10. data/lib/rails_wayback/bar_middleware.rb +235 -67
  11. data/lib/rails_wayback/bar_renderer.css +76 -0
  12. data/lib/rails_wayback/bar_renderer.html.erb +43 -0
  13. data/lib/rails_wayback/bar_renderer.js +361 -0
  14. data/lib/rails_wayback/bar_renderer.rb +70 -218
  15. data/lib/rails_wayback/bar_search.js +73 -0
  16. data/lib/rails_wayback/cache_inventory.rb +199 -0
  17. data/lib/rails_wayback/cli.rb +70 -3
  18. data/lib/rails_wayback/configuration.rb +13 -2
  19. data/lib/rails_wayback/controller_extensions.rb +71 -20
  20. data/lib/rails_wayback/doctor.rb +123 -0
  21. data/lib/rails_wayback/engine.rb +27 -19
  22. data/lib/rails_wayback/engine_mount.rb +15 -0
  23. data/lib/rails_wayback/git.rb +92 -8
  24. data/lib/rails_wayback/historical_asset_body.rb +41 -0
  25. data/lib/rails_wayback/historical_asset_helper.rb +68 -0
  26. data/lib/rails_wayback/historical_asset_resolver.rb +102 -0
  27. data/lib/rails_wayback/ref_policy.rb +64 -0
  28. data/lib/rails_wayback/render_context.rb +24 -0
  29. data/lib/rails_wayback/render_provenance.rb +84 -0
  30. data/lib/rails_wayback/render_tracker.rb +34 -0
  31. data/lib/rails_wayback/toggle.rb +10 -2
  32. data/lib/rails_wayback/version.rb +1 -1
  33. data/lib/rails_wayback/view_source.rb +171 -24
  34. data/lib/rails_wayback.rb +42 -6
  35. data/lib/tasks/rails_wayback.rake +17 -0
  36. data/rails-wayback.gemspec +2 -5
  37. metadata +21 -33
@@ -3,51 +3,111 @@
3
3
  require "fileutils"
4
4
  require "open3"
5
5
  require "pathname"
6
+ require "tempfile"
7
+ require "tmpdir"
8
+ require "rails_wayback/cache_inventory"
6
9
 
7
10
  module RailsWayback
8
- # Materialises the view/asset directories of a given git ref into a
9
- # sandbox under tmp/, so a controller can prepend that sandbox as a
10
- # view path and render the historical templates.
11
+ # Materialises the view/asset directories of a given git ref into the
12
+ # refs cache under tmp/, so a controller can prepend those directories
13
+ # as view paths and render the historical templates.
11
14
  #
12
15
  # Only the directories listed in `configuration.view_paths` and
13
16
  # `configuration.asset_paths` are pulled from the ref. Nothing is
14
17
  # executed, and the developer's real files are never mutated.
15
18
  class ViewSource
16
19
  # Bumped whenever the materialization format changes so stale
17
- # sandboxes from earlier gem versions get rebuilt instead of
20
+ # cached refs from earlier gem versions get rebuilt instead of
18
21
  # being reused.
19
- MATERIALIZATION_VERSION = "2"
22
+ MATERIALIZATION_VERSION = CacheInventory::MATERIALIZATION_VERSION
20
23
 
21
24
  attr_reader :configuration, :git
22
25
 
23
26
  def initialize(configuration: RailsWayback.configuration, git: RailsWayback::Git.new)
24
27
  @configuration = configuration
25
28
  @git = git
29
+ @cache_inventory = CacheInventory.new(configuration: configuration)
26
30
  end
27
31
 
28
32
  def materialize(ref)
29
33
  sha = git.resolve_ref(ref)
30
- target = configuration.refs_cache_path.join(sha)
31
- return target if fresh?(target, sha)
32
-
33
- FileUtils.rm_rf(target)
34
- FileUtils.mkdir_p(target)
35
- tracked_paths.each { |path| extract_path(sha, path, target) }
36
- write_marker(target, sha)
37
- target
34
+ with_cache_lock(File::LOCK_SH) { materialize_locked(sha).first }
38
35
  end
39
36
 
40
37
  def view_root_for(ref)
41
- target = materialize(ref)
42
- configuration.view_paths.map { |p| target.join(p) }.select(&:directory?)
38
+ view_roots(materialize(ref))
39
+ end
40
+
41
+ # Keeps a shared cache lease while the caller uses any files from the
42
+ # materialization. Pruning and cleanup take an exclusive lease, so they
43
+ # wait until every active user has finished before removing files.
44
+ def with_materialization(ref)
45
+ sha = git.resolve_ref(ref)
46
+ materialized = false
47
+
48
+ with_cache_lock(File::LOCK_SH) do
49
+ target, materialized = materialize_locked(sha)
50
+ yield target
51
+ end
52
+ ensure
53
+ automatic_prune if materialized
54
+ end
55
+
56
+ def with_view_roots(ref)
57
+ with_materialization(ref) do |target|
58
+ yield view_roots(target), target
59
+ end
43
60
  end
44
61
 
45
62
  def cleanup!
46
- FileUtils.rm_rf(configuration.refs_cache_path)
63
+ with_cache_lock(File::LOCK_EX) do
64
+ FileUtils.rm_rf(configuration.refs_cache_path)
65
+ FileUtils.rm_rf(configuration.cache_root_path.join("locks"))
66
+ end
67
+ end
68
+
69
+ def cache_snapshot
70
+ with_cache_lock(File::LOCK_SH) { cache_inventory.snapshot }
71
+ end
72
+
73
+ def prune!(preserve: [])
74
+ with_cache_lock(File::LOCK_EX) { cache_inventory.prune!(preserve: preserve) }
47
75
  end
48
76
 
49
77
  private
50
78
 
79
+ attr_reader :cache_inventory
80
+
81
+ def materialize_locked(sha)
82
+ target = configuration.refs_cache_path.join(sha)
83
+ if fresh?(target, sha)
84
+ touch_access(target)
85
+ return [target, false]
86
+ end
87
+
88
+ with_ref_lock(sha) do
89
+ if fresh?(target, sha)
90
+ touch_access(target)
91
+ next [target, false]
92
+ end
93
+
94
+ [build_materialization(sha, target), true]
95
+ end
96
+ end
97
+
98
+ def view_roots(target)
99
+ configuration.view_paths.map { |path| target.join(path) }.select(&:directory?)
100
+ end
101
+
102
+ def automatic_prune
103
+ result = prune!
104
+ return if result.limits_satisfied?
105
+
106
+ log_cache_warning("automatic pruning could not satisfy the configured cache limits")
107
+ rescue StandardError => e
108
+ log_cache_warning("automatic pruning failed: #{e.class}: #{e.message}")
109
+ end
110
+
51
111
  def tracked_paths
52
112
  (configuration.view_paths + configuration.asset_paths).uniq
53
113
  end
@@ -69,6 +129,43 @@ module RailsWayback
69
129
  marker_path(target).write("#{MATERIALIZATION_VERSION}:#{sha}")
70
130
  end
71
131
 
132
+ def touch_access(target)
133
+ FileUtils.touch(marker_path(target))
134
+ rescue SystemCallError => e
135
+ log_cache_warning("could not update cache access time: #{e.message}")
136
+ end
137
+
138
+ def log_cache_warning(message)
139
+ return unless defined?(Rails) && Rails.respond_to?(:logger)
140
+
141
+ Rails.logger&.warn("[rails-wayback] #{message}")
142
+ end
143
+
144
+ def build_materialization(sha, target)
145
+ refs_root = configuration.refs_cache_path
146
+ FileUtils.mkdir_p(refs_root)
147
+ staging = Pathname.new(Dir.mktmpdir(".#{sha}-building-", refs_root.to_s))
148
+
149
+ begin
150
+ tracked_paths.each { |path| extract_path(sha, path, staging) }
151
+ write_marker(staging, sha)
152
+ cache_inventory.write_metadata(staging, sha)
153
+
154
+ # Keep an existing materialization available until the replacement is
155
+ # complete. A failed extraction never removes the last known cache.
156
+ FileUtils.rm_rf(target)
157
+ File.rename(staging, target)
158
+ target
159
+ rescue MaterializationError
160
+ raise
161
+ rescue SystemCallError => e
162
+ raise MaterializationError,
163
+ "Could not materialize git ref #{sha}: #{e.message}"
164
+ ensure
165
+ FileUtils.rm_rf(staging) if staging.exist?
166
+ end
167
+ end
168
+
72
169
  # Uses `git archive | tar -x` to extract a subtree at a given ref.
73
170
  # This avoids the pitfalls of hand-rolled ls-tree + show pipelines
74
171
  # (encoding, binary files, missing directories, permissions) and
@@ -83,17 +180,67 @@ module RailsWayback
83
180
  archive_cmd = ["git", "-C", git.root.to_s, "archive", "--format=tar", spec]
84
181
  tar_cmd = ["tar", "-xf", "-", "-C", dest.to_s]
85
182
 
86
- Open3.pipeline(archive_cmd, tar_cmd)
183
+ Tempfile.create(["rails-wayback-extraction", ".log"]) do |errors|
184
+ statuses = extraction_pipeline(archive_cmd, tar_cmd, errors)
185
+ next if statuses.all?(&:success?)
186
+
187
+ errors.flush
188
+ errors.rewind
189
+ details = errors.read.strip
190
+ exits = statuses.map { |status| status.exitstatus || "unknown" }.join(", ")
191
+ message = "Could not extract #{spec} (pipeline exit statuses: #{exits})"
192
+ message = "#{message}: #{details}" unless details.empty?
193
+ raise MaterializationError, message
194
+ end
195
+ rescue SystemCallError => e
196
+ raise MaterializationError, "Could not extract #{spec}: #{e.message}"
87
197
  end
88
198
 
89
199
  def tree_exists?(sha, path)
90
- # `git cat-file -t <sha>:<path>` prints "tree" for directories and
91
- # "blob" for files. Anything else (or a non-zero exit) means the
92
- # path is not present at that ref, in which case we skip it.
93
- out, _err, status = Open3.capture3(
94
- "git", "-C", git.root.to_s, "cat-file", "-t", "#{sha}:#{path}"
95
- )
96
- status.success? && out.strip == "tree"
200
+ git.tree?(sha, path)
201
+ end
202
+
203
+ def extraction_pipeline(archive_cmd, tar_cmd, errors)
204
+ Open3.pipeline(archive_cmd, tar_cmd, err: errors)
205
+ rescue Errno::ENOENT => e
206
+ executable = [archive_cmd.first, tar_cmd.first].find do |candidate|
207
+ e.message.end_with?(" - #{candidate}")
208
+ end || "git or tar"
209
+ raise MaterializationError,
210
+ "Required executable `#{executable}` was not found in PATH. " \
211
+ "Run `bundle exec rails-wayback doctor` for diagnostics."
212
+ rescue SystemCallError => e
213
+ raise MaterializationError,
214
+ "Could not execute the git/tar extraction pipeline: #{e.message}. " \
215
+ "Run `bundle exec rails-wayback doctor` for diagnostics."
216
+ end
217
+
218
+ def with_cache_lock(mode, &)
219
+ with_file_lock(configuration.cache_root_path.join(".refs.lock"), mode, &)
220
+ end
221
+
222
+ def with_ref_lock(sha, &)
223
+ path = configuration.cache_root_path.join("locks", "#{sha}.lock")
224
+ with_file_lock(path, File::LOCK_EX, &)
225
+ end
226
+
227
+ def with_file_lock(path, mode)
228
+ file = acquire_file_lock(path, mode)
229
+ yield
230
+ ensure
231
+ file&.close
232
+ end
233
+
234
+ def acquire_file_lock(path, mode)
235
+ FileUtils.mkdir_p(path.dirname)
236
+ file = File.open(path, File::RDWR | File::CREAT, 0o644)
237
+ return file if file.flock(mode)
238
+
239
+ file.close
240
+ raise MaterializationError, "Could not lock the refs cache at #{path}"
241
+ rescue SystemCallError => e
242
+ file&.close
243
+ raise MaterializationError, "Could not lock the refs cache: #{e.message}"
97
244
  end
98
245
  end
99
246
  end
data/lib/rails_wayback.rb CHANGED
@@ -2,21 +2,34 @@
2
2
 
3
3
  require "rails_wayback/version"
4
4
  require "rails_wayback/configuration"
5
+ require "rails_wayback/render_context"
6
+ require "rails_wayback/render_provenance"
7
+ require "rails_wayback/render_tracker"
8
+ require "rails_wayback/asset_provenance"
5
9
  require "rails_wayback/toggle"
6
10
  require "rails_wayback/git"
11
+ require "rails_wayback/ref_policy"
12
+ require "rails_wayback/cache_inventory"
7
13
  require "rails_wayback/view_source"
14
+ require "rails_wayback/engine_mount"
15
+ require "rails_wayback/historical_asset_resolver"
16
+ require "rails_wayback/historical_asset_body"
17
+ require "rails_wayback/historical_asset_helper"
18
+ require "rails_wayback/doctor"
8
19
 
9
20
  module RailsWayback
10
21
  class Error < StandardError; end
11
22
  class DisabledError < Error; end
12
23
  class RefNotFoundError < Error; end
24
+ class MaterializationError < Error; end
13
25
  class RenderError < Error; end
14
26
 
15
- # Thread-local key where the render subscriber accumulates the
16
- # `identifier` of every template resolved during a single request.
17
- # The middleware resets this at the start of each request and reads
18
- # it back to report per-page diff coverage in the bar.
19
- RENDER_TRACKER_KEY = :rails_wayback_rendered_templates
27
+ RENDER_EVENTS = %w[
28
+ render_template.action_view
29
+ render_partial.action_view
30
+ render_layout.action_view
31
+ render_collection.action_view
32
+ ].freeze
20
33
 
21
34
  class << self
22
35
  def configure
@@ -37,11 +50,33 @@ module RailsWayback
37
50
  @toggle ||= Toggle.new(configuration)
38
51
  end
39
52
 
53
+ def current_environment
54
+ if defined?(Rails) && Rails.respond_to?(:env) && Rails.env
55
+ Rails.env.to_s
56
+ else
57
+ ENV["RAILS_ENV"] || ENV.fetch("RACK_ENV", nil)
58
+ end
59
+ end
60
+
61
+ def environment_allowed?
62
+ environment = current_environment.to_s
63
+ return true if environment.empty?
64
+
65
+ configuration.allowed_environments.map(&:to_s).include?(environment)
66
+ end
67
+
40
68
  def enabled?
41
- toggle.enabled?
69
+ environment_allowed? && toggle.enabled?
42
70
  end
43
71
 
44
72
  def enable!
73
+ unless environment_allowed?
74
+ allowed = configuration.allowed_environments.join(", ")
75
+ raise DisabledError,
76
+ "environment #{current_environment.inspect} is not allowed " \
77
+ "(allowed: #{allowed})"
78
+ end
79
+
45
80
  toggle.enable!
46
81
  end
47
82
 
@@ -51,6 +86,7 @@ module RailsWayback
51
86
 
52
87
  def root
53
88
  return Rails.root if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
89
+
54
90
  Pathname.new(Dir.pwd)
55
91
  end
56
92
  end
@@ -22,4 +22,21 @@ namespace :wayback do
22
22
  task clean: :environment do
23
23
  RailsWayback::CLI.start(["clean"])
24
24
  end
25
+
26
+ desc "Print rails-wayback ref cache usage"
27
+ task cache: :environment do
28
+ RailsWayback::CLI.start(["cache"])
29
+ end
30
+
31
+ desc "Prune least-recently-used refs to the configured limits"
32
+ task prune: :environment do
33
+ status = RailsWayback::CLI.start(["prune"])
34
+ raise "rails-wayback cache remains over its configured limits" unless status.zero?
35
+ end
36
+
37
+ desc "Check rails-wayback dependencies and host application readiness"
38
+ task doctor: :environment do
39
+ status = RailsWayback::CLI.start(["doctor"])
40
+ raise "rails-wayback doctor found critical problems" unless status.zero?
41
+ end
25
42
  end
@@ -18,7 +18,7 @@ Gem::Specification.new do |spec|
18
18
  DESC
19
19
  spec.homepage = "https://github.com/MrCesar107/rails-wayback"
20
20
  spec.license = "MIT"
21
- spec.required_ruby_version = ">= 3.1"
21
+ spec.required_ruby_version = ">= 3.3"
22
22
 
23
23
  spec.metadata["source_code_uri"] = spec.homepage
24
24
  spec.metadata["bug_tracker_uri"] = "#{spec.homepage}/issues"
@@ -37,8 +37,5 @@ Gem::Specification.new do |spec|
37
37
  spec.executables = ["rails-wayback"]
38
38
  spec.require_paths = ["lib"]
39
39
 
40
- spec.add_dependency "rails", ">= 7.0"
41
-
42
- spec.add_development_dependency "rspec", "~> 3.13"
43
- spec.add_development_dependency "rake", "~> 13.0"
40
+ spec.add_dependency "rails", ">= 8.0"
44
41
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails-wayback
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - MrCesar107
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-07-22 00:00:00.000000000 Z
11
+ date: 2026-08-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -16,42 +16,14 @@ dependencies:
16
16
  requirements:
17
17
  - - ">="
18
18
  - !ruby/object:Gem::Version
19
- version: '7.0'
19
+ version: '8.0'
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - ">="
25
25
  - !ruby/object:Gem::Version
26
- version: '7.0'
27
- - !ruby/object:Gem::Dependency
28
- name: rspec
29
- requirement: !ruby/object:Gem::Requirement
30
- requirements:
31
- - - "~>"
32
- - !ruby/object:Gem::Version
33
- version: '3.13'
34
- type: :development
35
- prerelease: false
36
- version_requirements: !ruby/object:Gem::Requirement
37
- requirements:
38
- - - "~>"
39
- - !ruby/object:Gem::Version
40
- version: '3.13'
41
- - !ruby/object:Gem::Dependency
42
- name: rake
43
- requirement: !ruby/object:Gem::Requirement
44
- requirements:
45
- - - "~>"
46
- - !ruby/object:Gem::Version
47
- version: '13.0'
48
- type: :development
49
- prerelease: false
50
- version_requirements: !ruby/object:Gem::Requirement
51
- requirements:
52
- - - "~>"
53
- - !ruby/object:Gem::Version
54
- version: '13.0'
26
+ version: '8.0'
55
27
  description: |
56
28
  rails-wayback is a Rails engine that lets you travel through git history to
57
29
  render past versions of your application's views inside the browser. It runs
@@ -69,6 +41,7 @@ files:
69
41
  - README.md
70
42
  - Rakefile
71
43
  - app/controllers/rails_wayback/application_controller.rb
44
+ - app/controllers/rails_wayback/assets_controller.rb
72
45
  - app/controllers/rails_wayback/pages_controller.rb
73
46
  - config/routes.rb
74
47
  - exe/rails-wayback
@@ -77,13 +50,28 @@ files:
77
50
  - lib/generators/rails_wayback/install/templates/initializer.rb.tt
78
51
  - lib/rails-wayback.rb
79
52
  - lib/rails_wayback.rb
53
+ - lib/rails_wayback/asset_provenance.rb
80
54
  - lib/rails_wayback/bar_middleware.rb
55
+ - lib/rails_wayback/bar_renderer.css
56
+ - lib/rails_wayback/bar_renderer.html.erb
57
+ - lib/rails_wayback/bar_renderer.js
81
58
  - lib/rails_wayback/bar_renderer.rb
59
+ - lib/rails_wayback/bar_search.js
60
+ - lib/rails_wayback/cache_inventory.rb
82
61
  - lib/rails_wayback/cli.rb
83
62
  - lib/rails_wayback/configuration.rb
84
63
  - lib/rails_wayback/controller_extensions.rb
64
+ - lib/rails_wayback/doctor.rb
85
65
  - lib/rails_wayback/engine.rb
66
+ - lib/rails_wayback/engine_mount.rb
86
67
  - lib/rails_wayback/git.rb
68
+ - lib/rails_wayback/historical_asset_body.rb
69
+ - lib/rails_wayback/historical_asset_helper.rb
70
+ - lib/rails_wayback/historical_asset_resolver.rb
71
+ - lib/rails_wayback/ref_policy.rb
72
+ - lib/rails_wayback/render_context.rb
73
+ - lib/rails_wayback/render_provenance.rb
74
+ - lib/rails_wayback/render_tracker.rb
87
75
  - lib/rails_wayback/toggle.rb
88
76
  - lib/rails_wayback/version.rb
89
77
  - lib/rails_wayback/view_source.rb
@@ -105,7 +93,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
105
93
  requirements:
106
94
  - - ">="
107
95
  - !ruby/object:Gem::Version
108
- version: '3.1'
96
+ version: '3.3'
109
97
  required_rubygems_version: !ruby/object:Gem::Requirement
110
98
  requirements:
111
99
  - - ">="