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.
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "pathname"
5
+
6
+ module RailsWayback
7
+ # Thin wrapper around shell git for the host application repository.
8
+ #
9
+ # We shell out to git instead of pulling in a dependency to keep the
10
+ # gem light. All commands are scoped to the host app root and return
11
+ # plain Ruby data structures.
12
+ class Git
13
+ Commit = Struct.new(:sha, :short_sha, :subject, :author, :date, keyword_init: true)
14
+
15
+ class GitError < StandardError; end
16
+
17
+ def initialize(root: nil)
18
+ @root = Pathname.new(root || RailsWayback.configuration.app_root_path)
19
+ end
20
+
21
+ def repository?
22
+ Dir.exist?(@root.join(".git")) || run("rev-parse", "--is-inside-work-tree").strip == "true"
23
+ rescue GitError
24
+ false
25
+ end
26
+
27
+ def current_branch
28
+ run("rev-parse", "--abbrev-ref", "HEAD").strip
29
+ end
30
+
31
+ def current_commit
32
+ run("rev-parse", "HEAD").strip
33
+ end
34
+
35
+ def branches
36
+ output = run("for-each-ref", "--format=%(refname:short)", "refs/heads/")
37
+ output.split("\n").map(&:strip).reject(&:empty?)
38
+ end
39
+
40
+ def commits(branch, limit: RailsWayback.configuration.max_commits)
41
+ output = run(
42
+ "log",
43
+ branch,
44
+ "--max-count=#{limit.to_i}",
45
+ "--pretty=format:%H%x1f%h%x1f%s%x1f%an%x1f%ad",
46
+ "--date=iso-strict"
47
+ )
48
+ output.split("\n").reject(&:empty?).map do |line|
49
+ sha, short, subject, author, date = line.split("\x1f", 5)
50
+ Commit.new(sha: sha, short_sha: short, subject: subject, author: author, date: date)
51
+ end
52
+ end
53
+
54
+ def resolve_ref(ref)
55
+ run("rev-parse", "--verify", "#{ref}^{commit}").strip
56
+ rescue GitError
57
+ raise RefNotFoundError, "Unknown git ref: #{ref.inspect}"
58
+ end
59
+
60
+ def show(ref, path)
61
+ run("show", "#{ref}:#{path}")
62
+ end
63
+
64
+ # Returns the local branch names that contain `sha` in their history.
65
+ # Used by the bar to reflect the branch you are RENDERING with when
66
+ # you travel, instead of the branch that is checked out on disk
67
+ # (which never moves — the gem never touches your working tree).
68
+ def branches_containing(sha)
69
+ output = run("branch", "--contains", sha, "--format=%(refname:short)")
70
+ output.split("\n").map(&:strip).reject(&:empty?)
71
+ rescue GitError
72
+ []
73
+ end
74
+
75
+ # Same as `branches_containing`, but picks the most contextual one
76
+ # for display in the bar when the developer did NOT provide an
77
+ # explicit branch alongside the sha:
78
+ # * if the current branch contains the sha, prefer it (keeps you in
79
+ # your own context — the sha is on your history line),
80
+ # * otherwise pick the first non-current local branch that contains
81
+ # it (typical case of traveling AWAY from your branch),
82
+ # * returns nil if no local branch contains the sha (detached ref).
83
+ def resolve_branch_for(sha)
84
+ candidates = branches_containing(sha)
85
+ return nil if candidates.empty?
86
+
87
+ here = current_branch
88
+ return here if candidates.include?(here)
89
+
90
+ candidates.first
91
+ end
92
+
93
+ # Returns the list of files that differ between `ref` and the current
94
+ # working tree (unstaged changes included). Optionally scoped to a set
95
+ # of pathspecs so we only report files inside the paths the gem
96
+ # actually swaps for rendering. Never raises: on git failure we log
97
+ # and return an empty list so a missing/broken ref never breaks the
98
+ # bar.
99
+ def diff_paths(ref, paths: [])
100
+ args = ["diff", "--name-only", ref]
101
+ args += ["--", *paths] unless paths.empty?
102
+ output = run(*args)
103
+ output.split("\n").map(&:strip).reject(&:empty?)
104
+ rescue GitError => e
105
+ if defined?(Rails) && Rails.respond_to?(:logger)
106
+ Rails.logger.warn("[rails-wayback] diff_paths(#{ref.inspect}) failed: #{e.message}")
107
+ end
108
+ []
109
+ end
110
+
111
+ def checkout_ref(ref, into:)
112
+ target = Pathname.new(into)
113
+ FileUtils.mkdir_p(target)
114
+ # `git --work-tree` + `checkout` writes the ref's contents into a
115
+ # detached directory without touching the developer's HEAD or index.
116
+ env = { "GIT_INDEX_FILE" => target.join(".rails_wayback_index").to_s }
117
+ run_with_env(env, "--work-tree=#{target}", "read-tree", ref)
118
+ run_with_env(env, "--work-tree=#{target}", "checkout-index", "--all", "--force")
119
+ target
120
+ ensure
121
+ FileUtils.rm_f(target.join(".rails_wayback_index")) if target
122
+ end
123
+
124
+ def root
125
+ @root
126
+ end
127
+
128
+ private
129
+
130
+ def run(*args)
131
+ run_with_env({}, *args)
132
+ end
133
+
134
+ def run_with_env(env, *args)
135
+ stdout, stderr, status = Open3.capture3(env, "git", "-C", @root.to_s, *args)
136
+ raise GitError, stderr.strip unless status.success?
137
+ stdout
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module RailsWayback
6
+ # Persists whether the engine should be mounted in the host app.
7
+ #
8
+ # The flag is a plain file inside `tmp/rails_wayback/enabled` (ignored by
9
+ # git). We keep it as a file so the CLI, rake tasks and running Rails
10
+ # process all agree on the same state without booting extra services.
11
+ class Toggle
12
+ def initialize(configuration = RailsWayback.configuration)
13
+ @configuration = configuration
14
+ end
15
+
16
+ def enabled?
17
+ configuration.toggle_file_path.exist?
18
+ end
19
+
20
+ def enable!
21
+ FileUtils.mkdir_p(configuration.toggle_file_path.dirname)
22
+ FileUtils.touch(configuration.toggle_file_path)
23
+ touch_routes_reload_marker
24
+ true
25
+ end
26
+
27
+ def disable!
28
+ FileUtils.rm_f(configuration.toggle_file_path)
29
+ touch_routes_reload_marker
30
+ true
31
+ end
32
+
33
+ def status
34
+ enabled? ? :on : :off
35
+ end
36
+
37
+ private
38
+
39
+ attr_reader :configuration
40
+
41
+ # Rails reloads routes in development when config/routes.rb changes.
42
+ # After toggling we bump its mtime so the next request picks up the
43
+ # conditional `mount` immediately, without asking the developer to
44
+ # touch files manually.
45
+ def touch_routes_reload_marker
46
+ routes_file = configuration.app_root_path.join("config", "routes.rb")
47
+ FileUtils.touch(routes_file) if routes_file.file?
48
+ rescue StandardError
49
+ # Best-effort: never fail toggling because of a filesystem hiccup.
50
+ nil
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsWayback
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "open3"
5
+ require "pathname"
6
+
7
+ 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
+ #
12
+ # Only the directories listed in `configuration.view_paths` and
13
+ # `configuration.asset_paths` are pulled from the ref. Nothing is
14
+ # executed, and the developer's real files are never mutated.
15
+ class ViewSource
16
+ # Bumped whenever the materialization format changes so stale
17
+ # sandboxes from earlier gem versions get rebuilt instead of
18
+ # being reused.
19
+ MATERIALIZATION_VERSION = "2"
20
+
21
+ attr_reader :configuration, :git
22
+
23
+ def initialize(configuration: RailsWayback.configuration, git: RailsWayback::Git.new)
24
+ @configuration = configuration
25
+ @git = git
26
+ end
27
+
28
+ def materialize(ref)
29
+ 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
38
+ end
39
+
40
+ def view_root_for(ref)
41
+ target = materialize(ref)
42
+ configuration.view_paths.map { |p| target.join(p) }.select(&:directory?)
43
+ end
44
+
45
+ def cleanup!
46
+ FileUtils.rm_rf(configuration.refs_cache_path)
47
+ end
48
+
49
+ private
50
+
51
+ def tracked_paths
52
+ (configuration.view_paths + configuration.asset_paths).uniq
53
+ end
54
+
55
+ def marker_path(target)
56
+ target.join(".rails_wayback.sha")
57
+ end
58
+
59
+ def fresh?(target, sha)
60
+ marker = marker_path(target)
61
+ return false unless marker.file?
62
+
63
+ contents = marker.read.strip
64
+ expected = "#{MATERIALIZATION_VERSION}:#{sha}"
65
+ contents == expected
66
+ end
67
+
68
+ def write_marker(target, sha)
69
+ marker_path(target).write("#{MATERIALIZATION_VERSION}:#{sha}")
70
+ end
71
+
72
+ # Uses `git archive | tar -x` to extract a subtree at a given ref.
73
+ # This avoids the pitfalls of hand-rolled ls-tree + show pipelines
74
+ # (encoding, binary files, missing directories, permissions) and
75
+ # works with any subdirectory a git version supports.
76
+ def extract_path(sha, path, target)
77
+ spec = "#{sha}:#{path}"
78
+ return unless tree_exists?(sha, path)
79
+
80
+ dest = target.join(path)
81
+ FileUtils.mkdir_p(dest)
82
+
83
+ archive_cmd = ["git", "-C", git.root.to_s, "archive", "--format=tar", spec]
84
+ tar_cmd = ["tar", "-xf", "-", "-C", dest.to_s]
85
+
86
+ Open3.pipeline(archive_cmd, tar_cmd)
87
+ end
88
+
89
+ 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"
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails_wayback/version"
4
+ require "rails_wayback/configuration"
5
+ require "rails_wayback/toggle"
6
+ require "rails_wayback/git"
7
+ require "rails_wayback/view_source"
8
+
9
+ module RailsWayback
10
+ class Error < StandardError; end
11
+ class DisabledError < Error; end
12
+ class RefNotFoundError < Error; end
13
+ class RenderError < Error; end
14
+
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
20
+
21
+ class << self
22
+ def configure
23
+ yield configuration if block_given?
24
+ configuration
25
+ end
26
+
27
+ def configuration
28
+ @configuration ||= Configuration.new
29
+ end
30
+
31
+ def reset_configuration!
32
+ @configuration = Configuration.new
33
+ @toggle = nil
34
+ end
35
+
36
+ def toggle
37
+ @toggle ||= Toggle.new(configuration)
38
+ end
39
+
40
+ def enabled?
41
+ toggle.enabled?
42
+ end
43
+
44
+ def enable!
45
+ toggle.enable!
46
+ end
47
+
48
+ def disable!
49
+ toggle.disable!
50
+ end
51
+
52
+ def root
53
+ return Rails.root if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
54
+ Pathname.new(Dir.pwd)
55
+ end
56
+ end
57
+ end
58
+
59
+ require "rails_wayback/engine" if defined?(Rails::Engine)
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails_wayback/cli"
4
+
5
+ namespace :wayback do
6
+ desc "Enable the rails-wayback UI"
7
+ task on: :environment do
8
+ RailsWayback::CLI.start(["on"])
9
+ end
10
+
11
+ desc "Disable the rails-wayback UI"
12
+ task off: :environment do
13
+ RailsWayback::CLI.start(["off"])
14
+ end
15
+
16
+ desc "Print the current rails-wayback state"
17
+ task status: :environment do
18
+ RailsWayback::CLI.start(["status"])
19
+ end
20
+
21
+ desc "Remove the rails-wayback ref cache under tmp/"
22
+ task clean: :environment do
23
+ RailsWayback::CLI.start(["clean"])
24
+ end
25
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/rails_wayback/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "rails-wayback"
7
+ spec.version = RailsWayback::VERSION
8
+ spec.authors = ["MrCesar107"]
9
+ spec.email = ["cesar.rodriguez.lara54@gmail.com"]
10
+
11
+ spec.summary = "Preview and compare Rails UI across git branches and commits."
12
+ spec.description = <<~DESC
13
+ rails-wayback is a Rails engine that lets you travel through git history to
14
+ render past versions of your application's views inside the browser. It runs
15
+ inside the same Rails process the developer is already using and can be
16
+ toggled on/off from the terminal so it never gets in the way of normal
17
+ development.
18
+ DESC
19
+ spec.homepage = "https://github.com/MrCesar107/rails-wayback"
20
+ spec.license = "MIT"
21
+ spec.required_ruby_version = ">= 3.1"
22
+
23
+ spec.metadata["source_code_uri"] = spec.homepage
24
+ spec.metadata["bug_tracker_uri"] = "#{spec.homepage}/issues"
25
+ spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
26
+ spec.metadata["rubygems_mfa_required"] = "true"
27
+
28
+ spec.files = Dir[
29
+ "{app,config,lib,exe}/**/*",
30
+ "MIT-LICENSE",
31
+ "Rakefile",
32
+ "README.md",
33
+ "rails-wayback.gemspec"
34
+ ].reject { |f| File.directory?(f) }
35
+
36
+ spec.bindir = "exe"
37
+ spec.executables = ["rails-wayback"]
38
+ spec.require_paths = ["lib"]
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"
44
+ end
metadata ADDED
@@ -0,0 +1,119 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rails-wayback
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - MrCesar107
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-07-22 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rails
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '7.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
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'
55
+ description: |
56
+ rails-wayback is a Rails engine that lets you travel through git history to
57
+ render past versions of your application's views inside the browser. It runs
58
+ inside the same Rails process the developer is already using and can be
59
+ toggled on/off from the terminal so it never gets in the way of normal
60
+ development.
61
+ email:
62
+ - cesar.rodriguez.lara54@gmail.com
63
+ executables:
64
+ - rails-wayback
65
+ extensions: []
66
+ extra_rdoc_files: []
67
+ files:
68
+ - MIT-LICENSE
69
+ - README.md
70
+ - Rakefile
71
+ - app/controllers/rails_wayback/application_controller.rb
72
+ - app/controllers/rails_wayback/pages_controller.rb
73
+ - config/routes.rb
74
+ - exe/rails-wayback
75
+ - lib/generators/rails_wayback/install/USAGE
76
+ - lib/generators/rails_wayback/install/install_generator.rb
77
+ - lib/generators/rails_wayback/install/templates/initializer.rb.tt
78
+ - lib/rails-wayback.rb
79
+ - lib/rails_wayback.rb
80
+ - lib/rails_wayback/bar_middleware.rb
81
+ - lib/rails_wayback/bar_renderer.rb
82
+ - lib/rails_wayback/cli.rb
83
+ - lib/rails_wayback/configuration.rb
84
+ - lib/rails_wayback/controller_extensions.rb
85
+ - lib/rails_wayback/engine.rb
86
+ - lib/rails_wayback/git.rb
87
+ - lib/rails_wayback/toggle.rb
88
+ - lib/rails_wayback/version.rb
89
+ - lib/rails_wayback/view_source.rb
90
+ - lib/tasks/rails_wayback.rake
91
+ - rails-wayback.gemspec
92
+ homepage: https://github.com/MrCesar107/rails-wayback
93
+ licenses:
94
+ - MIT
95
+ metadata:
96
+ source_code_uri: https://github.com/MrCesar107/rails-wayback
97
+ bug_tracker_uri: https://github.com/MrCesar107/rails-wayback/issues
98
+ changelog_uri: https://github.com/MrCesar107/rails-wayback/blob/main/CHANGELOG.md
99
+ rubygems_mfa_required: 'true'
100
+ post_install_message:
101
+ rdoc_options: []
102
+ require_paths:
103
+ - lib
104
+ required_ruby_version: !ruby/object:Gem::Requirement
105
+ requirements:
106
+ - - ">="
107
+ - !ruby/object:Gem::Version
108
+ version: '3.1'
109
+ required_rubygems_version: !ruby/object:Gem::Requirement
110
+ requirements:
111
+ - - ">="
112
+ - !ruby/object:Gem::Version
113
+ version: '0'
114
+ requirements: []
115
+ rubygems_version: 3.5.22
116
+ signing_key:
117
+ specification_version: 4
118
+ summary: Preview and compare Rails UI across git branches and commits.
119
+ test_files: []