rails-wayback 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 837e9fe96c57c95c97120d9763ce5f479f96de05ef809efd9ff28489a4747e33
4
+ data.tar.gz: 1123f82ce9d8e4a16b71fa318c881ca2fc0ec3a8ca50556a4bc3da720e1cab67
5
+ SHA512:
6
+ metadata.gz: 289a30da1575e6f95f57c15ea74172d2ae7b10a97a396d18ea449008300f2345583a39ae853e0bd1e3d28281de72465e10ec9e5d2b656974c697c547a467ac40
7
+ data.tar.gz: 67f4833b49c5c36381f672b8b48ec2368db8ef66bc04a10e5f70feb11fbf9c4c2beba98a0a311a46506fce8f942f037e90940b2002d381653842dbf24383e4c5
data/MIT-LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 MrCesar107
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,172 @@
1
+ # rails-wayback
2
+
3
+ Preview and compare Rails UI across git branches and commits, directly in the
4
+ browser, from inside the developer's own Rails process. Toggle it on and off
5
+ from the terminal so it never gets in the way of normal development.
6
+
7
+ ## Idea
8
+
9
+ When enabled, `rails-wayback` overlays a small bar at the bottom of every HTML
10
+ page of your app:
11
+
12
+ - Pick a branch and a commit.
13
+ - Hit **Travel**.
14
+ - The current URL reloads with `?_wayback_ref=<sha>`, and the app renders
15
+ that exact page using the **views/layouts/partials of the chosen ref**,
16
+ while keeping the current controller's data and models.
17
+
18
+ Design goals:
19
+
20
+ - **Zero configuration.** Drop the gem in, run the installer, turn it on.
21
+ - **One process only.** Uses the running dev app — no extra Rails server.
22
+ - **HTML render, not screenshots.**
23
+ - **Toggleable from the CLI.** Stays inert when you don't need it.
24
+
25
+ ## Install
26
+
27
+ Add the gem to the host app's `Gemfile`, in the development group:
28
+
29
+ ```ruby
30
+ group :development do
31
+ gem "rails-wayback"
32
+ end
33
+ ```
34
+
35
+ Then run the installer:
36
+
37
+ ```bash
38
+ bin/rails generate rails_wayback:install
39
+ ```
40
+
41
+ That adds a conditional mount to `config/routes.rb`, an initializer at
42
+ `config/initializers/rails_wayback.rb` (optional overrides), and a
43
+ `.gitignore` entry for `tmp/rails_wayback/`.
44
+
45
+ ## Usage
46
+
47
+ Turn the UI on when you need it, off when you don't:
48
+
49
+ ```bash
50
+ bundle exec rails-wayback on # enable and inject the bar in every page
51
+ bundle exec rails-wayback off # disable it
52
+ bundle exec rails-wayback status # print current state
53
+ bundle exec rails-wayback clean # drop the tmp/rails_wayback ref cache
54
+ ```
55
+
56
+ Same commands as rake tasks: `rake wayback:on`, `wayback:off`,
57
+ `wayback:status`, `wayback:clean`.
58
+
59
+ With the gem enabled, load any page of your app. A dark bar appears at the
60
+ bottom with:
61
+
62
+ - `Current branch` — dropdown of your local branches.
63
+ - `Current commit` — dropdown of the recent commits on that branch.
64
+ - `Travel` — reloads the current URL using views from that commit.
65
+ - `Return to HEAD` — appears while traveling; goes back to live views.
66
+
67
+ There is no dedicated `/rails-wayback` page. The gem stays quiet on assets,
68
+ ActionCable, and every URL under `/rails-wayback/*.json` (used by the bar to
69
+ fetch git data).
70
+
71
+ ## How "travel" actually works
72
+
73
+ `rails-wayback` never runs a second Rails server. Travel is just:
74
+
75
+ 1. The bar appends `?_wayback_ref=<sha>` to the current URL and reloads.
76
+ 2. A `before_action`, auto-included in every `ActionController::Base`
77
+ subclass, sees `_wayback_ref` and materialises that ref under
78
+ `tmp/rails_wayback/refs/<sha>/` (cached per commit).
79
+ 3. The same before_action calls `prepend_view_path` with the sandbox, so
80
+ Action View resolves templates from the ref before falling back to the
81
+ current tree.
82
+ 4. Your controller runs as usual, sets its `@` variables, and renders. The
83
+ visual layer (layouts, partials, ERB) comes from the past; the data and
84
+ business logic come from the present.
85
+
86
+ If a historical template references a helper or assign that no longer
87
+ exists, you'll get the same `ActionView` error you'd get from any missing
88
+ piece — the app itself never changes.
89
+
90
+ ## Configuration (optional)
91
+
92
+ `config/initializers/rails_wayback.rb`:
93
+
94
+ ```ruby
95
+ RailsWayback.configure do |config|
96
+ # config.view_paths = %w[app/views]
97
+ # config.asset_paths = %w[app/assets public]
98
+ # config.max_commits = 50
99
+ end
100
+ ```
101
+
102
+ Everything above has a sensible default. Delete the initializer if you
103
+ don't need to change anything.
104
+
105
+ ## Development
106
+
107
+ ```bash
108
+ bundle install
109
+ bundle exec rspec
110
+ ```
111
+
112
+ ## Release
113
+
114
+ Releases are cut by pushing an annotated `vX.Y.Z` git tag. GitHub Actions runs
115
+ the specs and pushes the gem to RubyGems using
116
+ [Trusted Publishing (OIDC)](https://guides.rubygems.org/trusted-publishing/),
117
+ so there are no API tokens stored on GitHub and MFA on the RubyGems account is
118
+ never in the way.
119
+
120
+ ### One-time setup
121
+
122
+ 1. On GitHub, go to **Settings → Environments → New environment** and create
123
+ an environment named exactly `release`. (Optionally add branch protection
124
+ so only `main` and tags can deploy to it.)
125
+ 2. On [rubygems.org](https://rubygems.org):
126
+ - For the very first release, go to **Profile → Trusted publishers →
127
+ Pending trusted publishers → Create** and fill in:
128
+ - RubyGem name: `rails-wayback`
129
+ - Repository owner: `MrCesar107`
130
+ - Repository name: `rails-wayback`
131
+ - Workflow filename: `release.yml`
132
+ - Environment: `release`
133
+ - For every release after 0.1.0, the pending publisher is promoted to a
134
+ normal trusted publisher automatically — nothing to configure again.
135
+ 3. Make sure MFA is enabled on your RubyGems account (the gemspec sets
136
+ `rubygems_mfa_required = true`).
137
+
138
+ ### Cutting a release
139
+
140
+ 1. Update `lib/rails_wayback/version.rb` following
141
+ [SemVer](https://semver.org/spec/v2.0.0.html).
142
+ 2. Move the pending entries in `CHANGELOG.md` from `[Unreleased]` into a new
143
+ `[X.Y.Z] - YYYY-MM-DD` section and update the compare/tag links at the
144
+ bottom of the file.
145
+ 3. Commit both files:
146
+ ```bash
147
+ git commit -am "Release vX.Y.Z"
148
+ ```
149
+ 4. Tag and push:
150
+ ```bash
151
+ git tag -a vX.Y.Z -m "Release vX.Y.Z"
152
+ git push origin main --follow-tags
153
+ ```
154
+ 5. Watch the `Release` workflow on GitHub. It refuses to publish if the tag
155
+ doesn't match `RailsWayback::VERSION`, so a typo in the tag will fail
156
+ loudly before touching RubyGems.
157
+
158
+ ### Manual fallback
159
+
160
+ If the workflow is broken and you need to publish from your laptop:
161
+
162
+ ```bash
163
+ gem build rails-wayback.gemspec
164
+ gem push rails-wayback-X.Y.Z.gem
165
+ ```
166
+
167
+ `gem push` will ask for your RubyGems credentials and MFA code the first
168
+ time.
169
+
170
+ ## License
171
+
172
+ MIT
data/Rakefile ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+
5
+ begin
6
+ require "rspec/core/rake_task"
7
+ RSpec::Core::RakeTask.new(:spec)
8
+ task default: :spec
9
+ rescue LoadError
10
+ # RSpec is only available in development
11
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsWayback
4
+ class ApplicationController < ::ActionController::API
5
+ before_action :ensure_enabled!
6
+
7
+ private
8
+
9
+ def ensure_enabled!
10
+ return if RailsWayback.enabled?
11
+
12
+ render json: { error: "rails-wayback is disabled" }, status: :service_unavailable
13
+ end
14
+
15
+ def git
16
+ @git ||= RailsWayback::Git.new
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsWayback
4
+ # JSON endpoints consumed by the bar's JavaScript to populate the
5
+ # branch and commit selectors. All rendering of the actual UI lives
6
+ # in the middleware; this controller only exposes git metadata.
7
+ class PagesController < ApplicationController
8
+ def branches
9
+ current_branch = safe_call { git.current_branch }
10
+ current_commit = safe_call { git.current_commit }
11
+ branches = safe_call { git.branches }
12
+
13
+ render json: {
14
+ current_branch: current_branch.value,
15
+ current_commit: current_commit.value,
16
+ branches: branches.value || [],
17
+ errors: [branches.error].compact
18
+ }
19
+ end
20
+
21
+ def commits
22
+ branch = params[:branch].to_s.sub(/\.json\z/, "")
23
+ result = safe_call { git.commits(branch) }
24
+
25
+ list = (result.value || []).map do |commit|
26
+ {
27
+ sha: commit.sha,
28
+ short_sha: commit.short_sha,
29
+ subject: commit.subject,
30
+ author: commit.author,
31
+ date: commit.date
32
+ }
33
+ end
34
+
35
+ render json: {
36
+ branch: branch,
37
+ commits: list,
38
+ error: result.error
39
+ }
40
+ end
41
+
42
+ private
43
+
44
+ Result = Struct.new(:value, :error)
45
+
46
+ def safe_call
47
+ Result.new(yield, nil)
48
+ rescue RailsWayback::Git::GitError => e
49
+ Rails.logger.warn("[rails-wayback] #{e.class}: #{e.message}") if defined?(Rails)
50
+ Result.new(nil, "#{e.class}: #{e.message}")
51
+ end
52
+ end
53
+ end
data/config/routes.rb ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ RailsWayback::Engine.routes.draw do
4
+ # `format: false` keeps Rails from parsing a trailing `.json` as an
5
+ # implicit format, and the glob `*branch` allows branch names that
6
+ # contain slashes (e.g. "feature/foo"). The controller trims a
7
+ # trailing `.json` defensively in case a client still appends it.
8
+ get "branches", to: "pages#branches", format: false, as: :branches
9
+ get "commits/*branch", to: "pages#commits", format: false, as: :commits
10
+ end
data/exe/rails-wayback ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
5
+
6
+ require "rails_wayback/cli"
7
+
8
+ exit(RailsWayback::CLI.start(ARGV) || 0)
@@ -0,0 +1,8 @@
1
+ Description:
2
+ Installs rails-wayback in the current Rails application.
3
+
4
+ Adds a conditional mount to config/routes.rb and drops an initializer
5
+ at config/initializers/rails_wayback.rb.
6
+
7
+ Example:
8
+ rails generate rails_wayback:install
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ module RailsWayback
6
+ module Generators
7
+ class InstallGenerator < ::Rails::Generators::Base
8
+ source_root File.expand_path("templates", __dir__)
9
+
10
+ def copy_initializer
11
+ template "initializer.rb.tt", "config/initializers/rails_wayback.rb"
12
+ end
13
+
14
+ def mount_engine
15
+ route_snippet = <<~ROUTE
16
+ mount RailsWayback::Engine => "/rails-wayback" if defined?(RailsWayback) && RailsWayback.enabled?
17
+ ROUTE
18
+
19
+ routes_file = "config/routes.rb"
20
+
21
+ if File.file?(routes_file) && File.read(routes_file).include?("RailsWayback::Engine")
22
+ say_status :skip, "RailsWayback::Engine mount already present in #{routes_file}"
23
+ else
24
+ route route_snippet.strip
25
+ end
26
+ end
27
+
28
+ def update_gitignore
29
+ gitignore = ".gitignore"
30
+ entry = "/tmp/rails_wayback/\n"
31
+ if File.file?(gitignore) && !File.read(gitignore).include?("tmp/rails_wayback")
32
+ append_to_file(gitignore, entry)
33
+ elsif !File.file?(gitignore)
34
+ create_file(gitignore, entry)
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Optional configuration for the rails-wayback engine.
4
+ #
5
+ # The engine is only mounted when it is toggled on from the terminal
6
+ # (see `bundle exec rails-wayback on|off|status`). When it is on, a
7
+ # floating bar is injected at the bottom of every HTML page of your app
8
+ # so you can pick a branch/commit and travel to that ref's views for
9
+ # the current URL. Delete this file if the defaults below work for you.
10
+ RailsWayback.configure do |config|
11
+ # Directories (relative to Rails.root) whose contents are pulled from
12
+ # the target git ref and used to render the historical preview.
13
+ # config.view_paths = %w[app/views]
14
+ # config.asset_paths = %w[app/assets public]
15
+
16
+ # How many recent commits per branch are shown in the bar dropdown.
17
+ # config.max_commits = 50
18
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails_wayback"
@@ -0,0 +1,199 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+ require "rails_wayback/bar_renderer"
5
+
6
+ module RailsWayback
7
+ # Rack middleware that appends the wayback bar to every successful
8
+ # HTML response of the host application while the gem is enabled.
9
+ # Skips assets, ActionCable, non-2xx responses and non-HTML bodies so
10
+ # it never touches anything but the developer-facing pages.
11
+ class BarMiddleware
12
+ SKIP_PATH_PREFIXES = %w[
13
+ /assets
14
+ /packs
15
+ /rails/
16
+ /cable
17
+ /rails-wayback
18
+ ].freeze
19
+
20
+ def initialize(app)
21
+ @app = app
22
+ end
23
+
24
+ def call(env)
25
+ # Always reset the per-request render tracker before invoking the
26
+ # app so the middleware can accurately report which templates the
27
+ # current page rendered. Cheap and thread-safe (Thread.current).
28
+ Thread.current[RailsWayback::RENDER_TRACKER_KEY] = []
29
+
30
+ status, headers, body = @app.call(env)
31
+ return [status, headers, body] unless inject?(env, status, headers)
32
+
33
+ html = read_body(body)
34
+ snippet = build_snippet(env)
35
+ new_html = inject(html, snippet)
36
+
37
+ new_headers = headers.dup
38
+ length_key = new_headers.key?("Content-Length") ? "Content-Length" : (new_headers.key?("content-length") ? "content-length" : nil)
39
+ new_headers[length_key] = new_html.bytesize.to_s if length_key
40
+
41
+ [status, new_headers, [new_html]]
42
+ end
43
+
44
+ private
45
+
46
+ def inject?(env, status, headers)
47
+ return false unless RailsWayback.enabled?
48
+ return false unless status.to_i.between?(200, 299) || status.to_i.between?(400, 499)
49
+
50
+ path = env["PATH_INFO"].to_s
51
+ return false if SKIP_PATH_PREFIXES.any? { |prefix| path.start_with?(prefix) }
52
+
53
+ content_type = headers["Content-Type"] || headers["content-type"] || ""
54
+ return false unless content_type.include?("text/html")
55
+
56
+ true
57
+ end
58
+
59
+ def read_body(body)
60
+ buffer = +""
61
+ body.each { |chunk| buffer << chunk.to_s }
62
+ buffer
63
+ ensure
64
+ body.close if body.respond_to?(:close)
65
+ end
66
+
67
+ def inject(html, snippet)
68
+ if html.include?("</body>")
69
+ html.sub("</body>", "#{snippet}</body>")
70
+ else
71
+ html + snippet
72
+ end
73
+ end
74
+
75
+ def build_snippet(env)
76
+ git = RailsWayback::Git.new
77
+ active_ref = extract_active_ref(env)
78
+ active_branch = active_ref ? active_branch_for(env, git, active_ref) : nil
79
+
80
+ renderer = BarRenderer.new(
81
+ current_branch: safe(:current_branch, git),
82
+ current_commit: safe(:current_commit, git),
83
+ active_ref: active_ref,
84
+ active_branch: active_branch,
85
+ engine_mount: engine_mount,
86
+ diff_info: active_ref ? build_diff_info(git, active_ref) : nil
87
+ )
88
+ renderer.render
89
+ rescue StandardError => e
90
+ Rails.logger.warn("[rails-wayback] failed to render bar: #{e.class}: #{e.message}") if defined?(Rails)
91
+ ""
92
+ end
93
+
94
+ def safe(method, git)
95
+ git.public_send(method)
96
+ rescue StandardError
97
+ ""
98
+ end
99
+
100
+ # Determines the branch label to show in the bar while traveling.
101
+ # Prefers the branch the user explicitly picked in the bar (passed
102
+ # via the `_wayback_branch` query param, so it survives the reload).
103
+ # Falls back to inferring it from git — useful when a developer opens
104
+ # a shared URL that only carries `_wayback_ref`.
105
+ def active_branch_for(env, git, ref)
106
+ explicit = extract_active_branch(env)
107
+ return explicit if explicit && !explicit.empty?
108
+
109
+ git.resolve_branch_for(ref)
110
+ rescue StandardError
111
+ nil
112
+ end
113
+
114
+ # Builds the diff summary shown in the bar when a ref is active.
115
+ # `changed_files`: view/asset paths that differ between the ref and
116
+ # the current working tree, always scoped to the paths the gem
117
+ # actually swaps. `rendered_from_ref`: relative paths (under
118
+ # app/views, app/components, ...) that the current request rendered
119
+ # from the sandbox. `matched`: intersection — the templates on this
120
+ # page that actually reflect a real diff between branches.
121
+ def build_diff_info(git, ref)
122
+ config = RailsWayback.configuration
123
+ tracked_paths = (config.view_paths + config.asset_paths).uniq
124
+ changed_files = git.diff_paths(ref, paths: tracked_paths)
125
+
126
+ rendered_from_ref = rendered_relative_paths(config)
127
+ matched = changed_files & rendered_from_ref
128
+
129
+ {
130
+ changed_files: changed_files,
131
+ rendered_from_ref: rendered_from_ref,
132
+ matched: matched
133
+ }
134
+ rescue StandardError => e
135
+ Rails.logger.warn("[rails-wayback] failed to build diff info: #{e.class}: #{e.message}") if defined?(Rails)
136
+ nil
137
+ end
138
+
139
+ def rendered_relative_paths(config)
140
+ tracker = Array(Thread.current[RailsWayback::RENDER_TRACKER_KEY])
141
+ return [] if tracker.empty?
142
+
143
+ sandbox_prefix = config.refs_cache_path.to_s
144
+ # Sandbox identifiers look like:
145
+ # /.../tmp/rails_wayback/refs/<sha>/app/views/foo/_bar.html.haml
146
+ # Strip up to and including the sha directory to get back
147
+ # `app/views/foo/_bar.html.haml`, which matches `git diff --name-only`.
148
+ strip_re = %r{\A#{Regexp.escape(sandbox_prefix)}/[^/]+/}
149
+
150
+ tracker.filter_map do |identifier|
151
+ next unless identifier.start_with?(sandbox_prefix)
152
+ identifier.sub(strip_re, "")
153
+ end.uniq
154
+ end
155
+
156
+ # Query params take priority (one-off overrides / shared links);
157
+ # cookies keep the travel state alive as the developer navigates
158
+ # around the app without repeating the params in every URL.
159
+ def extract_active_ref(env)
160
+ extract_query_param(env, "_wayback_ref") ||
161
+ extract_cookie(env, "rails_wayback_ref")
162
+ end
163
+
164
+ def extract_active_branch(env)
165
+ extract_query_param(env, "_wayback_branch") ||
166
+ extract_cookie(env, "rails_wayback_branch")
167
+ end
168
+
169
+ def extract_query_param(env, name)
170
+ query = env["QUERY_STRING"].to_s
171
+ return nil if query.empty?
172
+ prefix = "#{name}="
173
+ match = query.split("&").find { |pair| pair.start_with?(prefix) }
174
+ return nil unless match
175
+
176
+ value = CGI.unescape(match.split("=", 2).last.to_s)
177
+ value.empty? ? nil : value
178
+ end
179
+
180
+ def extract_cookie(env, name)
181
+ header = env["HTTP_COOKIE"].to_s
182
+ return nil if header.empty?
183
+ prefix = "#{name}="
184
+ match = header.split(/;\s*/).find { |pair| pair.start_with?(prefix) }
185
+ return nil unless match
186
+
187
+ value = CGI.unescape(match.split("=", 2).last.to_s)
188
+ value.empty? ? nil : value
189
+ end
190
+
191
+ def engine_mount
192
+ RailsWayback::Engine.routes.default_url_options[:script_name] ||
193
+ RailsWayback::Engine.routes.find_script_name({}) ||
194
+ "/rails-wayback"
195
+ rescue StandardError
196
+ "/rails-wayback"
197
+ end
198
+ end
199
+ end