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.
- checksums.yaml +4 -4
- data/README.md +120 -7
- data/Rakefile +17 -1
- data/app/controllers/rails_wayback/assets_controller.rb +78 -0
- data/app/controllers/rails_wayback/pages_controller.rb +50 -9
- data/config/routes.rb +5 -0
- data/lib/generators/rails_wayback/install/install_generator.rb +5 -3
- data/lib/generators/rails_wayback/install/templates/initializer.rb.tt +18 -1
- data/lib/rails_wayback/asset_provenance.rb +41 -0
- data/lib/rails_wayback/bar_middleware.rb +235 -67
- data/lib/rails_wayback/bar_renderer.css +76 -0
- data/lib/rails_wayback/bar_renderer.html.erb +43 -0
- data/lib/rails_wayback/bar_renderer.js +361 -0
- data/lib/rails_wayback/bar_renderer.rb +70 -218
- data/lib/rails_wayback/bar_search.js +73 -0
- data/lib/rails_wayback/cache_inventory.rb +199 -0
- data/lib/rails_wayback/cli.rb +70 -3
- data/lib/rails_wayback/configuration.rb +13 -2
- data/lib/rails_wayback/controller_extensions.rb +71 -20
- data/lib/rails_wayback/doctor.rb +123 -0
- data/lib/rails_wayback/engine.rb +27 -19
- data/lib/rails_wayback/engine_mount.rb +15 -0
- data/lib/rails_wayback/git.rb +92 -8
- data/lib/rails_wayback/historical_asset_body.rb +41 -0
- data/lib/rails_wayback/historical_asset_helper.rb +68 -0
- data/lib/rails_wayback/historical_asset_resolver.rb +102 -0
- data/lib/rails_wayback/ref_policy.rb +64 -0
- data/lib/rails_wayback/render_context.rb +24 -0
- data/lib/rails_wayback/render_provenance.rb +84 -0
- data/lib/rails_wayback/render_tracker.rb +34 -0
- data/lib/rails_wayback/toggle.rb +10 -2
- data/lib/rails_wayback/version.rb +1 -1
- data/lib/rails_wayback/view_source.rb +171 -24
- data/lib/rails_wayback.rb +42 -6
- data/lib/tasks/rails_wayback.rake +17 -0
- data/rails-wayback.gemspec +2 -5
- 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
|
|
8
|
-
#
|
|
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
|
-
|
|
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
|
|
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
|
|
107
|
-
|
|
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
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
283
|
-
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
resetBtn.addEventListener("click", reset);
|
|
144
|
+
%( nonce="#{escape(@csp_nonce)}")
|
|
145
|
+
end
|
|
294
146
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|