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
@@ -1,54 +1,38 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
4
3
  require "cgi"
4
+ require "erb"
5
+ require "rails_wayback/version"
5
6
 
6
7
  module RailsWayback
7
- # Builds the HTML/CSS/JS payload that the middleware injects into
8
- # every HTML response so the developer can travel from any page of
9
- # their app without visiting a dedicated route.
8
+ # Builds the HTML payload that the middleware injects into every HTML
9
+ # response and references the toolbar's same-origin CSS and JavaScript.
10
10
  class BarRenderer
11
+ TEMPLATE = ERB.new(File.read(File.expand_path("bar_renderer.html.erb", __dir__))).freeze
12
+ STYLES = File.read(File.expand_path("bar_renderer.css", __dir__)).freeze
13
+ SEARCH_SCRIPT = File.read(File.expand_path("bar_search.js", __dir__)).freeze
14
+ SCRIPT = [SEARCH_SCRIPT, File.read(File.expand_path("bar_renderer.js", __dir__))].join("\n").freeze
15
+
11
16
  def initialize(current_branch:, current_commit:, active_ref: nil, active_branch: nil,
12
- engine_mount: "/rails-wayback", diff_info: nil)
17
+ engine_mount: "/rails-wayback", diff_info: nil, csp_nonce: nil, ref_error: nil)
13
18
  @current_branch = current_branch
14
19
  @current_commit = current_commit
15
20
  @active_ref = active_ref
16
21
  @active_branch = active_branch
17
- @engine_mount = engine_mount
22
+ @engine_mount = normalize_mount(engine_mount)
18
23
  @diff_info = diff_info
24
+ @csp_nonce = csp_nonce.to_s
25
+ @ref_error = ref_error.to_s
19
26
  end
20
27
 
21
28
  def render
22
- <<~HTML
23
- <style data-rails-wayback>#{styles}</style>
24
- <div id="rails-wayback-bar" data-mount="#{escape(@engine_mount)}"
25
- data-branch="#{escape(@current_branch)}"
26
- data-commit="#{escape(@current_commit)}"
27
- data-active-ref="#{escape(@active_ref.to_s)}"
28
- data-active-branch="#{escape(@active_branch.to_s)}">
29
- <div class="rw-bar-inner">
30
- <div class="rw-field">
31
- <label>#{escape(branch_label)}</label>
32
- <select data-rw-branch></select>
33
- </div>
34
- <div class="rw-field rw-flex">
35
- <label>#{escape(commit_label)}</label>
36
- <select data-rw-commit></select>
37
- </div>
38
- <div class="rw-state" data-rw-state></div>
39
- <button type="button" data-rw-travel class="rw-btn rw-btn-primary">Travel</button>
40
- <button type="button" data-rw-reset class="rw-btn rw-btn-ghost" hidden>Return to HEAD</button>
41
- </div>
42
- #{diff_summary_html}
43
- </div>
44
- <script data-rails-wayback>#{script}</script>
45
- HTML
29
+ TEMPLATE.result(binding)
46
30
  end
47
31
 
48
32
  private
49
33
 
50
34
  def branch_label
51
- @active_ref ? "Rendering branch" : "Current branch"
35
+ @active_ref ? "Rendering ref" : "Current ref"
52
36
  end
53
37
 
54
38
  def commit_label
@@ -87,12 +71,51 @@ module RailsWayback
87
71
 
88
72
  <<~HTML.strip
89
73
  <div class="#{css_class}">
74
+ #{provenance_html}
75
+ #{asset_provenance_html}
90
76
  <span class="rw-diff-msg">#{escape(message)}</span>
91
77
  #{list_html}
92
78
  </div>
93
79
  HTML
94
80
  end
95
81
 
82
+ def provenance_html
83
+ historical = Array(@diff_info[:rendered_from_ref])
84
+ current = Array(@diff_info[:rendered_from_current])
85
+
86
+ message, css_class = case @diff_info[:preview_mode]&.to_sym
87
+ when :mixed
88
+ ["Mixed preview: #{count_label(historical.size, "historical template")}, " \
89
+ "#{count_label(current.size, "current fallback")}.", "rw-provenance-mixed"]
90
+ when :historical
91
+ ["Historical preview: #{count_label(historical.size, "historical template")}.",
92
+ "rw-provenance-historical"]
93
+ when :current_fallback
94
+ ["Current fallback: no historical templates rendered " \
95
+ "(#{count_label(current.size, "current template")}).", "rw-provenance-current"]
96
+ else
97
+ return ""
98
+ end
99
+
100
+ %(<span class="rw-provenance #{css_class}">#{escape(message)}</span>)
101
+ end
102
+
103
+ def count_label(count, singular)
104
+ "#{count} #{singular}#{"s" unless count == 1}"
105
+ end
106
+
107
+ def asset_provenance_html
108
+ historical = Array(@diff_info[:historical_assets])
109
+ current = Array(@diff_info[:current_asset_fallbacks])
110
+ return "" if historical.empty? && current.empty?
111
+
112
+ labels = []
113
+ labels << count_label(historical.size, "historical asset") if historical.any?
114
+ labels << count_label(current.size, "current asset fallback") if current.any?
115
+ css_class = current.any? ? "rw-provenance-mixed" : "rw-provenance-historical"
116
+ %(<span class="rw-provenance #{css_class}">Assets: #{escape(labels.join(", "))}.</span>)
117
+ end
118
+
96
119
  def diff_details(summary, files)
97
120
  items = files.map { |f| %(<li>#{escape(f)}</li>) }.join
98
121
  <<~HTML.strip
@@ -103,199 +126,28 @@ module RailsWayback
103
126
  HTML
104
127
  end
105
128
 
106
- def styles
107
- <<~CSS
108
- #rails-wayback-bar {
109
- position: fixed; left: 0; right: 0; bottom: 0; z-index: 2147483000;
110
- font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
111
- font-size: 12px; color: #e6e9ef;
112
- background: rgba(15, 17, 21, 0.96);
113
- border-top: 1px solid #2a2f3a;
114
- box-shadow: 0 -8px 20px rgba(0,0,0,.35);
115
- }
116
- #rails-wayback-bar * { box-sizing: border-box; }
117
- #rails-wayback-bar .rw-bar-inner {
118
- display: flex; align-items: end; gap: 12px; padding: 8px 12px;
119
- }
120
- #rails-wayback-bar .rw-field { display: flex; flex-direction: column; gap: 3px; min-width: 180px; }
121
- #rails-wayback-bar .rw-flex { flex: 1; }
122
- #rails-wayback-bar label {
123
- color: #8a93a6; text-transform: uppercase; letter-spacing: .06em; font-size: 10px;
124
- }
125
- #rails-wayback-bar select {
126
- background: #0b0d12; color: #e6e9ef;
127
- border: 1px solid #2a2f3a; border-radius: 4px;
128
- padding: 5px 8px; font-family: inherit; font-size: 12px; width: 100%;
129
- }
130
- #rails-wayback-bar .rw-state { color: #8a93a6; font-size: 11px; margin: 0 4px; }
131
- #rails-wayback-bar .rw-state.rw-active { color: #ffd479; }
132
- #rails-wayback-bar .rw-btn {
133
- border: 0; border-radius: 4px; padding: 8px 14px; cursor: pointer;
134
- font-family: inherit; font-size: 12px; font-weight: 600;
135
- }
136
- #rails-wayback-bar .rw-btn-primary { background: #6ea8fe; color: #0b0d12; }
137
- #rails-wayback-bar .rw-btn-ghost { background: transparent; color: #8a93a6; border: 1px solid #2a2f3a; }
138
- #rails-wayback-bar .rw-btn:disabled { opacity: .5; cursor: not-allowed; }
139
- #rails-wayback-bar .rw-diff {
140
- border-top: 1px solid #2a2f3a;
141
- padding: 6px 12px; font-size: 11px; color: #8a93a6;
142
- display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
143
- }
144
- #rails-wayback-bar .rw-diff-match .rw-diff-msg { color: #7ee787; }
145
- #rails-wayback-bar .rw-diff-miss .rw-diff-msg { color: #ffd479; }
146
- #rails-wayback-bar .rw-diff-neutral .rw-diff-msg { color: #8a93a6; }
147
- #rails-wayback-bar .rw-diff-details { color: #8a93a6; }
148
- #rails-wayback-bar .rw-diff-details summary {
149
- cursor: pointer; user-select: none;
150
- }
151
- #rails-wayback-bar .rw-diff-details[open] {
152
- flex-basis: 100%;
153
- }
154
- #rails-wayback-bar .rw-diff-details ul {
155
- margin: 6px 0 0; padding: 6px 10px; list-style: none;
156
- background: #0b0d12; border: 1px solid #2a2f3a; border-radius: 4px;
157
- max-height: 200px; overflow: auto;
158
- font-family: inherit; font-size: 11px;
159
- }
160
- #rails-wayback-bar .rw-diff-details li { padding: 2px 0; color: #c8cfdc; }
161
- body { padding-bottom: 56px !important; }
162
- body:has(#rails-wayback-bar .rw-diff) { padding-bottom: 88px !important; }
163
- CSS
129
+ def stylesheet_url
130
+ asset_url("bar.css")
164
131
  end
165
132
 
166
- def script
167
- <<~JS
168
- (function () {
169
- var bar = document.getElementById("rails-wayback-bar");
170
- if (!bar) return;
171
-
172
- var mount = bar.dataset.mount;
173
- var headBranch = bar.dataset.branch;
174
- var headCommit = bar.dataset.commit;
175
- var activeRef = bar.dataset.activeRef || "";
176
- var activeBranch = bar.dataset.activeBranch || "";
177
- // While traveling, reflect the ref's branch/commit in the
178
- // dropdowns instead of the developer's local HEAD, so the bar
179
- // matches what is actually being rendered.
180
- var selectedBranch = activeBranch || headBranch;
181
- var selectedCommit = activeRef || headCommit;
182
-
183
- var branchSel = bar.querySelector("[data-rw-branch]");
184
- var commitSel = bar.querySelector("[data-rw-commit]");
185
- var stateEl = bar.querySelector("[data-rw-state]");
186
- var travelBtn = bar.querySelector("[data-rw-travel]");
187
- var resetBtn = bar.querySelector("[data-rw-reset]");
188
-
189
- function setState() {
190
- if (activeRef) {
191
- var label = activeBranch
192
- ? activeBranch + "@" + activeRef.slice(0, 7)
193
- : activeRef.slice(0, 7);
194
- stateEl.textContent = "Traveling: " + label;
195
- stateEl.classList.add("rw-active");
196
- resetBtn.hidden = false;
197
- } else {
198
- stateEl.textContent = "Live";
199
- stateEl.classList.remove("rw-active");
200
- resetBtn.hidden = true;
201
- }
202
- }
203
-
204
- function showError(msg) {
205
- stateEl.textContent = msg;
206
- stateEl.classList.add("rw-active");
207
- }
208
-
209
- function loadBranches() {
210
- fetch(mount + "/branches", { headers: { Accept: "application/json" } })
211
- .then(function (r) {
212
- if (!r.ok) throw new Error("HTTP " + r.status + " on /branches");
213
- return r.json();
214
- })
215
- .then(function (data) {
216
- branchSel.innerHTML = "";
217
- (data.branches || []).forEach(function (b) {
218
- var opt = document.createElement("option");
219
- opt.value = b; opt.textContent = b;
220
- if (b === selectedBranch) opt.selected = true;
221
- branchSel.appendChild(opt);
222
- });
223
- if ((data.errors || []).length > 0) showError(data.errors.join(" | "));
224
- if (branchSel.value) loadCommits(branchSel.value);
225
- })
226
- .catch(function (err) {
227
- showError("branches: " + err.message);
228
- });
229
- }
230
-
231
- function loadCommits(branch) {
232
- travelBtn.disabled = true;
233
- fetch(mount + "/commits/" + encodeURI(branch), { headers: { Accept: "application/json" } })
234
- .then(function (r) {
235
- if (!r.ok) throw new Error("HTTP " + r.status + " on /commits/" + branch);
236
- return r.json();
237
- })
238
- .then(function (data) {
239
- commitSel.innerHTML = "";
240
- (data.commits || []).forEach(function (c) {
241
- var opt = document.createElement("option");
242
- opt.value = c.sha;
243
- opt.textContent = c.short_sha + " — " + (c.subject || "").slice(0, 80);
244
- if (c.sha === selectedCommit) opt.selected = true;
245
- commitSel.appendChild(opt);
246
- });
247
- if (data.error) {
248
- showError("commits: " + data.error);
249
- } else if (!activeRef) {
250
- setState();
251
- }
252
- travelBtn.disabled = !commitSel.value;
253
- })
254
- .catch(function (err) {
255
- showError("commits: " + err.message);
256
- });
257
- }
258
-
259
- function setCookie(name, value) {
260
- document.cookie = name + "=" + encodeURIComponent(value) + "; Path=/; SameSite=Lax";
261
- }
262
-
263
- function clearCookie(name) {
264
- document.cookie = name + "=; Path=/; Max-Age=0; SameSite=Lax";
265
- }
133
+ def javascript_url
134
+ asset_url("bar.js")
135
+ end
266
136
 
267
- // Travel is a *session*, not a one-off request: the ref lives
268
- // in a cookie so it survives every subsequent link click. The
269
- // URL params are added just so a first-time landing (or a
270
- // shared link) still works even before the cookie is set.
271
- function travel() {
272
- var sha = commitSel.value;
273
- if (!sha) return;
274
- setCookie("rails_wayback_ref", sha);
275
- if (branchSel.value) setCookie("rails_wayback_branch", branchSel.value);
276
- var url = new URL(window.location.href);
277
- url.searchParams.set("_wayback_ref", sha);
278
- if (branchSel.value) url.searchParams.set("_wayback_branch", branchSel.value);
279
- window.location.assign(url.toString());
280
- }
137
+ def asset_url(filename)
138
+ "#{@engine_mount}/assets/#{filename}?v=#{RailsWayback::VERSION}"
139
+ end
281
140
 
282
- function reset() {
283
- clearCookie("rails_wayback_ref");
284
- clearCookie("rails_wayback_branch");
285
- var url = new URL(window.location.href);
286
- url.searchParams.delete("_wayback_ref");
287
- url.searchParams.delete("_wayback_branch");
288
- window.location.assign(url.toString());
289
- }
141
+ def nonce_attribute
142
+ return "" if @csp_nonce.empty?
290
143
 
291
- branchSel.addEventListener("change", function (e) { loadCommits(e.target.value); });
292
- travelBtn.addEventListener("click", travel);
293
- resetBtn.addEventListener("click", reset);
144
+ %( nonce="#{escape(@csp_nonce)}")
145
+ end
294
146
 
295
- setState();
296
- loadBranches();
297
- })();
298
- JS
147
+ def normalize_mount(value)
148
+ mount = value.to_s
149
+ mount = "/rails-wayback" if mount.empty?
150
+ mount.sub(%r{/+\z}, "")
299
151
  end
300
152
  end
301
153
  end
@@ -0,0 +1,73 @@
1
+ (function (root, factory) {
2
+ const search = factory();
3
+
4
+ if (typeof module !== "undefined" && module.exports) module.exports = search;
5
+ if (root) root.RailsWaybackSearch = search;
6
+ })(typeof globalThis !== "undefined" ? globalThis : this, function () {
7
+ function normalizeQuery(value) {
8
+ return String(value || "").trim().toLocaleLowerCase();
9
+ }
10
+
11
+ function searchableText(item, fields) {
12
+ return fields.map(function (field) {
13
+ return String(item[field] || "");
14
+ }).join(" ").toLocaleLowerCase();
15
+ }
16
+
17
+ function preserveSelection(items, matches, selected, valueField) {
18
+ const selectedValue = String(selected || "");
19
+ if (!selectedValue || matches.some(function (item) {
20
+ return String(item[valueField] || "") === selectedValue;
21
+ })) return matches;
22
+
23
+ const selectedItem = items.find(function (item) {
24
+ return String(item[valueField] || "") === selectedValue;
25
+ });
26
+ if (!selectedItem) return matches;
27
+
28
+ return items.filter(function (item) {
29
+ return item === selectedItem || matches.indexOf(item) !== -1;
30
+ });
31
+ }
32
+
33
+ function search(items, query, selected, fields, valueField) {
34
+ const collection = Array.isArray(items) ? items : [];
35
+ const normalized = normalizeQuery(query);
36
+ const matches = normalized
37
+ ? collection.filter(function (item) {
38
+ return searchableText(item, fields).includes(normalized);
39
+ })
40
+ : collection.slice();
41
+
42
+ return {
43
+ matches: matches,
44
+ visible: preserveSelection(collection, matches, selected, valueField)
45
+ };
46
+ }
47
+
48
+ function searchReferences(references, query, selected) {
49
+ return search(
50
+ references,
51
+ query,
52
+ selected,
53
+ ["full_name", "name", "label", "type"],
54
+ "full_name"
55
+ );
56
+ }
57
+
58
+ function searchCommits(commits, query, selected) {
59
+ return search(
60
+ commits,
61
+ query,
62
+ selected,
63
+ ["sha", "short_sha", "subject", "author", "date"],
64
+ "sha"
65
+ );
66
+ }
67
+
68
+ return {
69
+ normalizeQuery: normalizeQuery,
70
+ searchReferences: searchReferences,
71
+ searchCommits: searchCommits
72
+ };
73
+ });
@@ -0,0 +1,199 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "find"
4
+ require "fileutils"
5
+ require "json"
6
+ require "pathname"
7
+ require "time"
8
+
9
+ module RailsWayback
10
+ # Reads cache metadata and applies explicit LRU pruning policies.
11
+ class CacheInventory
12
+ MATERIALIZATION_VERSION = "4"
13
+ METADATA_VERSION = 1
14
+ MARKER_NAME = ".rails_wayback.sha"
15
+ METADATA_NAME = ".rails_wayback.meta.json"
16
+ SHA_PATTERN = /\A(?:[0-9a-f]{40}|[0-9a-f]{64})\z/i
17
+
18
+ Entry = Struct.new(
19
+ :sha, :path, :size_bytes, :file_count, :last_accessed_at, :valid,
20
+ keyword_init: true
21
+ ) do
22
+ def valid?
23
+ valid
24
+ end
25
+ end
26
+
27
+ Snapshot = Struct.new(:refs, keyword_init: true) do
28
+ def ref_count
29
+ refs.size
30
+ end
31
+
32
+ def size_bytes
33
+ refs.sum(&:size_bytes)
34
+ end
35
+
36
+ def file_count
37
+ refs.sum(&:file_count)
38
+ end
39
+ end
40
+
41
+ PruneResult = Struct.new(:removed, :snapshot, :limits_satisfied, keyword_init: true) do
42
+ def removed_size_bytes
43
+ removed.sum(&:size_bytes)
44
+ end
45
+
46
+ def limits_satisfied?
47
+ limits_satisfied
48
+ end
49
+ end
50
+
51
+ def initialize(configuration: RailsWayback.configuration)
52
+ @configuration = configuration
53
+ end
54
+
55
+ def snapshot
56
+ Snapshot.new(refs: entry_paths.filter_map { |path| build_entry(path) })
57
+ end
58
+
59
+ def write_metadata(path, sha)
60
+ size_bytes, file_count = measure(path)
61
+ payload = {
62
+ metadata_version: METADATA_VERSION,
63
+ materialization_version: MATERIALIZATION_VERSION,
64
+ sha: sha,
65
+ size_bytes: size_bytes,
66
+ file_count: file_count,
67
+ created_at: Time.now.utc.iso8601
68
+ }
69
+ metadata_path(path).write(JSON.generate(payload))
70
+ end
71
+
72
+ # Caller must hold the cache's exclusive lock.
73
+ def prune!(preserve: [])
74
+ preserved = Array(preserve).map(&:to_s)
75
+ entries = snapshot.refs
76
+ removed = remove_invalid(entries, preserved)
77
+ remaining = entries - removed
78
+
79
+ removable = remaining.reject { |entry| preserved.include?(entry.sha) }
80
+ .sort_by { |entry| [entry.last_accessed_at, entry.sha] }
81
+ while limits_exceeded?(remaining) && removable.any?
82
+ entry = removable.shift
83
+ remove_entry(entry)
84
+ removed << entry
85
+ remaining.delete(entry)
86
+ end
87
+
88
+ final_snapshot = Snapshot.new(refs: remaining)
89
+ PruneResult.new(
90
+ removed: removed,
91
+ snapshot: final_snapshot,
92
+ limits_satisfied: !limits_exceeded?(remaining)
93
+ )
94
+ end
95
+
96
+ private
97
+
98
+ attr_reader :configuration
99
+
100
+ def entry_paths
101
+ root = configuration.refs_cache_path
102
+ return [] unless root.directory?
103
+
104
+ root.children.sort_by { |path| path.basename.to_s }.select do |path|
105
+ path.basename.to_s.match?(SHA_PATTERN) && File.lstat(path).directory?
106
+ rescue SystemCallError
107
+ false
108
+ end
109
+ end
110
+
111
+ def build_entry(path)
112
+ sha = path.basename.to_s.downcase
113
+ marker = marker_path(path)
114
+ valid = marker.file? && marker.read.strip == "#{MATERIALIZATION_VERSION}:#{sha}"
115
+ size_bytes, file_count = stored_metrics(path, sha) || measure(path)
116
+ accessed = marker.file? ? marker.mtime : path.mtime
117
+ Entry.new(
118
+ sha: sha,
119
+ path: path,
120
+ size_bytes: size_bytes,
121
+ file_count: file_count,
122
+ last_accessed_at: accessed,
123
+ valid: valid
124
+ )
125
+ rescue SystemCallError
126
+ nil
127
+ end
128
+
129
+ def stored_metrics(path, sha)
130
+ metadata = metadata_path(path)
131
+ return unless metadata.file?
132
+
133
+ payload = JSON.parse(metadata.read)
134
+ return unless payload["metadata_version"] == METADATA_VERSION
135
+ return unless payload["materialization_version"] == MATERIALIZATION_VERSION
136
+ return unless payload["sha"] == sha
137
+
138
+ size_bytes = Integer(payload["size_bytes"])
139
+ file_count = Integer(payload["file_count"])
140
+ return if size_bytes.negative? || file_count.negative?
141
+
142
+ [size_bytes, file_count]
143
+ rescue JSON::ParserError, ArgumentError, TypeError
144
+ nil
145
+ end
146
+
147
+ def measure(path)
148
+ size_bytes = 0
149
+ file_count = 0
150
+ Find.find(path.to_s) do |entry_path|
151
+ stat = File.lstat(entry_path)
152
+ next if stat.directory?
153
+ next if [marker_path(path).to_s, metadata_path(path).to_s].include?(entry_path)
154
+
155
+ size_bytes += stat.size
156
+ file_count += 1
157
+ end
158
+ [size_bytes, file_count]
159
+ end
160
+
161
+ def remove_invalid(entries, preserved)
162
+ entries.reject(&:valid?).reject { |entry| preserved.include?(entry.sha) }.each do |entry|
163
+ remove_entry(entry)
164
+ end
165
+ end
166
+
167
+ def remove_entry(entry)
168
+ FileUtils.rm_rf(entry.path)
169
+ FileUtils.rm_f(configuration.cache_root_path.join("locks", "#{entry.sha}.lock"))
170
+ return unless entry.path.exist?
171
+
172
+ raise MaterializationError, "Could not remove cached ref #{entry.sha}"
173
+ end
174
+
175
+ def limits_exceeded?(entries)
176
+ count_limit = normalized_limit(configuration.max_cached_refs)
177
+ byte_limit = normalized_limit(configuration.max_cache_bytes)
178
+ count_exceeded = count_limit && entries.size > count_limit
179
+ bytes_exceeded = byte_limit && entries.sum(&:size_bytes) > byte_limit
180
+ count_exceeded || bytes_exceeded
181
+ end
182
+
183
+ def normalized_limit(value)
184
+ return if value.nil?
185
+
186
+ [Integer(value), 0].max
187
+ rescue ArgumentError, TypeError
188
+ 0
189
+ end
190
+
191
+ def marker_path(path)
192
+ path.join(MARKER_NAME)
193
+ end
194
+
195
+ def metadata_path(path)
196
+ path.join(METADATA_NAME)
197
+ end
198
+ end
199
+ end