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
@@ -10,7 +10,8 @@ module RailsWayback
10
10
  # rails-wayback status # print current state
11
11
  # rails-wayback clean # remove the ref cache under tmp/
12
12
  class CLI
13
- COMMANDS = %w[on off status clean help].freeze
13
+ COMMANDS = %w[on off status cache prune clean doctor help].freeze
14
+ DOCTOR_LABELS = { ok: "OK", warning: "WARN", error: "ERROR" }.freeze
14
15
 
15
16
  def self.start(argv, stdout: $stdout, stderr: $stderr)
16
17
  new(stdout: stdout, stderr: stderr).run(argv)
@@ -27,7 +28,10 @@ module RailsWayback
27
28
  when "on" then cmd_on
28
29
  when "off" then cmd_off
29
30
  when "status" then cmd_status
31
+ when "cache" then cmd_cache
32
+ when "prune" then cmd_prune
30
33
  when "clean" then cmd_clean
34
+ when "doctor" then cmd_doctor
31
35
  when "help", "--help", "-h" then cmd_help
32
36
  else
33
37
  @stderr.puts "Unknown command: #{command}"
@@ -40,8 +44,11 @@ module RailsWayback
40
44
 
41
45
  def cmd_on
42
46
  RailsWayback.enable!
43
- @stdout.puts "rails-wayback: enabled (mounted at /rails-wayback on the next request)"
47
+ @stdout.puts "rails-wayback: enabled for this server session (mounted at /rails-wayback on the next request)"
44
48
  0
49
+ rescue RailsWayback::DisabledError => e
50
+ @stderr.puts "rails-wayback: cannot enable: #{e.message}"
51
+ 1
45
52
  end
46
53
 
47
54
  def cmd_off
@@ -51,7 +58,12 @@ module RailsWayback
51
58
  end
52
59
 
53
60
  def cmd_status
54
- @stdout.puts "rails-wayback: #{RailsWayback.toggle.status}"
61
+ if RailsWayback.environment_allowed?
62
+ @stdout.puts "rails-wayback: #{RailsWayback.toggle.status}"
63
+ else
64
+ environment = RailsWayback.current_environment.inspect
65
+ @stdout.puts "rails-wayback: unavailable (environment #{environment} is not allowed)"
66
+ end
55
67
  0
56
68
  end
57
69
 
@@ -61,6 +73,36 @@ module RailsWayback
61
73
  0
62
74
  end
63
75
 
76
+ def cmd_cache
77
+ snapshot = RailsWayback::ViewSource.new.cache_snapshot
78
+ @stdout.puts cache_summary(snapshot)
79
+ 0
80
+ rescue RailsWayback::MaterializationError => e
81
+ @stderr.puts "rails-wayback: cannot inspect cache: #{e.message}"
82
+ 1
83
+ end
84
+
85
+ def cmd_prune
86
+ result = RailsWayback::ViewSource.new.prune!
87
+ @stdout.puts "rails-wayback: pruned #{result.removed.size} ref(s), " \
88
+ "freed #{format_bytes(result.removed_size_bytes)}"
89
+ @stdout.puts cache_summary(result.snapshot)
90
+ result.limits_satisfied? ? 0 : 1
91
+ rescue RailsWayback::MaterializationError => e
92
+ @stderr.puts "rails-wayback: cannot prune cache: #{e.message}"
93
+ 1
94
+ end
95
+
96
+ def cmd_doctor
97
+ result = RailsWayback::Doctor.new.call
98
+ @stdout.puts "rails-wayback doctor"
99
+ result.checks.each do |check|
100
+ @stdout.puts "[#{DOCTOR_LABELS.fetch(check.status)}] #{check.name}: #{check.message}"
101
+ end
102
+ @stdout.puts(result.ready? ? "Result: ready" : "Result: not ready")
103
+ result.exit_status
104
+ end
105
+
64
106
  def cmd_help
65
107
  @stdout.puts <<~HELP
66
108
  Usage: rails-wayback <command>
@@ -69,10 +111,35 @@ module RailsWayback
69
111
  on Enable the wayback UI in this Rails app
70
112
  off Disable the wayback UI
71
113
  status Print whether the UI is currently enabled
114
+ cache Print ref cache usage and configured limits
115
+ prune Remove least-recently-used refs until limits are satisfied
72
116
  clean Remove the tmp/rails_wayback ref cache
117
+ doctor Check dependencies and host application readiness
73
118
  help Show this help
74
119
  HELP
75
120
  0
76
121
  end
122
+
123
+ def cache_summary(snapshot)
124
+ config = RailsWayback.configuration
125
+ count_limit = config.max_cached_refs.nil? ? "unlimited" : config.max_cached_refs
126
+ byte_limit = config.max_cache_bytes.nil? ? "unlimited" : format_bytes(config.max_cache_bytes)
127
+ "rails-wayback cache: #{snapshot.ref_count} ref(s), #{format_bytes(snapshot.size_bytes)}, " \
128
+ "#{snapshot.file_count} file(s); limits: #{count_limit} refs / #{byte_limit}"
129
+ end
130
+
131
+ def format_bytes(value)
132
+ bytes = Integer(value)
133
+ return "#{bytes} B" if bytes < 1024
134
+
135
+ units = %w[KiB MiB GiB TiB]
136
+ amount = bytes / 1024.0
137
+ unit = units.shift
138
+ while amount >= 1024 && units.any?
139
+ amount /= 1024
140
+ unit = units.shift
141
+ end
142
+ format("%<amount>.1f %<unit>s", amount: amount, unit: unit)
143
+ end
77
144
  end
78
145
  end
@@ -10,14 +10,25 @@ module RailsWayback
10
10
  DEFAULT_VIEW_PATHS = ["app/views", "app/components"].freeze
11
11
  DEFAULT_ASSET_PATHS = ["app/assets", "public"].freeze
12
12
  DEFAULT_MAX_COMMITS = 50
13
+ DEFAULT_MAX_RESPONSE_BYTES = 2 * 1024 * 1024
14
+ DEFAULT_ALLOWED_ENVIRONMENTS = %w[development test].freeze
15
+ DEFAULT_TRUSTED_REF_PATTERNS = ["refs/heads/*"].freeze
16
+ DEFAULT_MAX_CACHED_REFS = 25
17
+ DEFAULT_MAX_CACHE_BYTES = 512 * 1024 * 1024
13
18
 
14
- attr_accessor :view_paths, :asset_paths, :max_commits,
15
- :app_root, :cache_root, :toggle_file
19
+ attr_accessor :view_paths, :asset_paths, :max_commits, :allowed_environments,
20
+ :max_response_bytes, :trusted_ref_patterns, :max_cached_refs,
21
+ :max_cache_bytes, :app_root, :cache_root, :toggle_file
16
22
 
17
23
  def initialize
18
24
  @view_paths = DEFAULT_VIEW_PATHS.dup
19
25
  @asset_paths = DEFAULT_ASSET_PATHS.dup
20
26
  @max_commits = DEFAULT_MAX_COMMITS
27
+ @max_response_bytes = DEFAULT_MAX_RESPONSE_BYTES
28
+ @allowed_environments = DEFAULT_ALLOWED_ENVIRONMENTS.dup
29
+ @trusted_ref_patterns = DEFAULT_TRUSTED_REF_PATTERNS.dup
30
+ @max_cached_refs = DEFAULT_MAX_CACHED_REFS
31
+ @max_cache_bytes = DEFAULT_MAX_CACHE_BYTES
21
32
  @app_root = nil
22
33
  @cache_root = nil
23
34
  @toggle_file = nil
@@ -1,12 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsWayback
4
- # Auto-included in every `ActionController::Base` subclass through the
5
- # engine's initializer. Does nothing on ordinary requests. When a
4
+ # Included in every `ActionController::Base` subclass. When a
6
5
  # request carries `?_wayback_ref=<sha>` and the gem is enabled, it
7
- # materialises the ref and prepends the sandbox to the view paths so
8
- # the current controller renders historical templates while keeping
9
- # its own instance variables intact.
6
+ # leases the materialised ref and prepends its directories to the view
7
+ # paths so the current controller renders historical templates while
8
+ # keeping its own instance variables intact.
10
9
  module ControllerExtensions
11
10
  extend ActiveSupport::Concern
12
11
 
@@ -15,8 +14,9 @@ module RailsWayback
15
14
  RESPONSE_HEADER = "X-RailsWayback-Ref"
16
15
 
17
16
  included do
18
- before_action :__rails_wayback_prepend_views
17
+ around_action :__rails_wayback_with_views
19
18
  helper_method :rails_wayback_active_ref
19
+ helper RailsWayback::HistoricalAssetHelper
20
20
  end
21
21
 
22
22
  def rails_wayback_active_ref
@@ -25,17 +25,63 @@ module RailsWayback
25
25
 
26
26
  private
27
27
 
28
- def __rails_wayback_prepend_views
29
- return unless RailsWayback.enabled?
28
+ def __rails_wayback_with_views(&action)
29
+ return action.call unless RailsWayback.enabled?
30
30
 
31
31
  ref = __rails_wayback_ref_from_request
32
- return unless ref
32
+ return action.call unless ref
33
33
 
34
- dirs = RailsWayback::ViewSource.new.view_root_for(ref)
34
+ selection = authorize_wayback_ref(ref)
35
+ return action.call unless selection
35
36
 
37
+ request.env[RailsWayback::RefPolicy::ENV_KEY] = selection
38
+ if selection.rejected?
39
+ reject_wayback_ref(selection)
40
+ return action.call
41
+ end
42
+
43
+ render_with_wayback_views(selection, &action)
44
+ end
45
+
46
+ def authorize_wayback_ref(ref)
47
+ RailsWayback::RefPolicy.new.authorize(ref)
48
+ rescue RailsWayback::RefNotFoundError, RailsWayback::Git::GitError => e
49
+ handle_wayback_error(e)
50
+ nil
51
+ end
52
+
53
+ def render_with_wayback_views(selection, &action)
54
+ action_started = false
55
+ RailsWayback::ViewSource.new.with_view_roots(selection.sha) do |dirs, root|
56
+ with_wayback_materialization(root) do
57
+ prepend_wayback_views(selection, dirs)
58
+ action_started = true
59
+ action.call
60
+ end
61
+ end
62
+ rescue RailsWayback::RefNotFoundError, RailsWayback::Git::GitError => e
63
+ raise if action_started
64
+
65
+ handle_wayback_error(e)
66
+ action.call
67
+ end
68
+
69
+ def with_wayback_materialization(root)
70
+ request.env[RailsWayback::HistoricalAssetHelper::MATERIALIZATION_ROOT_ENV] = root
71
+ yield
72
+ ensure
73
+ request.env.delete(RailsWayback::HistoricalAssetHelper::MATERIALIZATION_ROOT_ENV)
74
+ end
75
+
76
+ def handle_wayback_error(error)
77
+ log_warning("#{error.class}: #{error.message}")
78
+ write_wayback_header("error:#{error.class}")
79
+ end
80
+
81
+ def prepend_wayback_views(selection, dirs)
36
82
  if dirs.empty?
37
- log_warning("no view directories materialised for ref #{ref}")
38
- set_header("empty:#{ref}")
83
+ log_warning("no view directories materialised for ref #{selection.sha}")
84
+ write_wayback_header("empty:#{selection.sha}")
39
85
  return
40
86
  end
41
87
 
@@ -44,14 +90,11 @@ module RailsWayback
44
90
  # lookup context.
45
91
  dirs.each { |dir| prepend_view_path(dir.to_s) }
46
92
 
47
- @__rails_wayback_active_ref = ref
48
- set_header(ref)
93
+ @__rails_wayback_active_ref = selection.sha
94
+ write_wayback_header(selection.sha)
49
95
 
50
- log_info("prepended #{dirs.size} view path(s) from ref #{ref}")
96
+ log_info("prepended #{dirs.size} view path(s) from ref #{selection.sha}")
51
97
  log_info("resolver order: #{resolver_summary}")
52
- rescue RailsWayback::RefNotFoundError, RailsWayback::Git::GitError => e
53
- log_warning("#{e.class}: #{e.message}")
54
- set_header("error:#{e.class}")
55
98
  end
56
99
 
57
100
  # Params take precedence for one-off overrides (e.g. a shared URL);
@@ -65,6 +108,13 @@ module RailsWayback
65
108
  cookies[WAYBACK_COOKIE].to_s.then { |v| v.empty? ? nil : v }
66
109
  end
67
110
 
111
+ def reject_wayback_ref(selection)
112
+ response.delete_cookie(WAYBACK_COOKIE, path: "/")
113
+ response.delete_cookie("rails_wayback_branch", path: "/")
114
+ log_warning(selection.message)
115
+ write_wayback_header("rejected:#{selection.reason}")
116
+ end
117
+
68
118
  def resolver_summary
69
119
  lookup_context.view_paths.to_a.map { |r| resolver_path(r) }.join(" | ")
70
120
  rescue StandardError
@@ -74,11 +124,12 @@ module RailsWayback
74
124
  def resolver_path(resolver)
75
125
  return resolver.to_path if resolver.respond_to?(:to_path)
76
126
  return resolver.path if resolver.respond_to?(:path)
127
+
77
128
  resolver.class.name
78
129
  end
79
130
 
80
- def set_header(value)
81
- response.set_header(RESPONSE_HEADER, value) if response
131
+ def write_wayback_header(value)
132
+ response&.set_header(RESPONSE_HEADER, value)
82
133
  rescue StandardError
83
134
  nil
84
135
  end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "open3"
5
+ require "pathname"
6
+ require "tempfile"
7
+
8
+ module RailsWayback
9
+ # Performs explicit, non-destructive readiness checks for the host app.
10
+ class Doctor
11
+ Check = Struct.new(:name, :status, :message, keyword_init: true) do
12
+ def error?
13
+ status == :error
14
+ end
15
+ end
16
+
17
+ Result = Struct.new(:checks, keyword_init: true) do
18
+ def ready?
19
+ checks.none?(&:error?)
20
+ end
21
+
22
+ def exit_status
23
+ ready? ? 0 : 1
24
+ end
25
+ end
26
+
27
+ def initialize(configuration: RailsWayback.configuration, git: nil,
28
+ environment: RailsWayback.current_environment, capture3: nil)
29
+ @configuration = configuration
30
+ @git = git || Git.new(root: configuration.app_root_path)
31
+ @environment = environment.to_s
32
+ @capture3 = capture3 || Open3.method(:capture3)
33
+ end
34
+
35
+ def call
36
+ root_check = app_root_check
37
+ checks = [environment_check, root_check]
38
+ git_check = executable_check("Git", "git")
39
+ checks << git_check
40
+ checks << repository_check unless git_check.error? || root_check.error?
41
+ checks << executable_check("tar", "tar", version_optional: true)
42
+ checks << if root_check.error?
43
+ check("Cache directory", :error, "not checked because the application root is missing")
44
+ else
45
+ cache_check
46
+ end
47
+ checks << configured_paths_check
48
+ Result.new(checks: checks)
49
+ end
50
+
51
+ private
52
+
53
+ attr_reader :configuration, :git, :environment, :capture3
54
+
55
+ def environment_check
56
+ allowed = Array(configuration.allowed_environments).map(&:to_s)
57
+ return check("Rails environment", :warning, "not detected; allowed: #{allowed.join(", ")}") if environment.empty?
58
+ return check("Rails environment", :ok, "#{environment} (allowed)") if allowed.include?(environment)
59
+
60
+ check("Rails environment", :error, "#{environment} is not allowed; allowed: #{allowed.join(", ")}")
61
+ end
62
+
63
+ def app_root_check
64
+ root = configuration.app_root_path
65
+ return check("Application root", :ok, root.to_s) if root.directory?
66
+
67
+ check("Application root", :error, "directory does not exist: #{root}")
68
+ end
69
+
70
+ def executable_check(label, executable, version_optional: false)
71
+ stdout, stderr, status = capture3.call(executable, "--version")
72
+ details = [stdout, stderr].map(&:strip).find { |value| !value.empty? }
73
+ return check(label, :ok, details || "available") if status.success?
74
+ return check(label, :warning, "available, but `#{executable} --version` is unsupported") if version_optional
75
+
76
+ check(label, :error, "#{executable} --version exited with status #{status.exitstatus}")
77
+ rescue Errno::ENOENT
78
+ check(label, :error, "required executable `#{executable}` was not found in PATH")
79
+ rescue SystemCallError => e
80
+ check(label, :error, "could not execute `#{executable}`: #{e.message}")
81
+ end
82
+
83
+ def repository_check
84
+ return check("Git repository", :ok, git.root.to_s) if git.repository?
85
+
86
+ check("Git repository", :error, "no Git work tree found at #{git.root}")
87
+ end
88
+
89
+ def cache_check
90
+ root = configuration.cache_root_path
91
+ FileUtils.mkdir_p(root)
92
+ Tempfile.create(".rails-wayback-doctor-", root) { |file| file.write("ok") }
93
+ check("Cache directory", :ok, "writable: #{root}")
94
+ rescue SystemCallError => e
95
+ check("Cache directory", :error, "not writable at #{root}: #{e.message}")
96
+ end
97
+
98
+ def configured_paths_check
99
+ paths = (Array(configuration.view_paths) + Array(configuration.asset_paths)).map(&:to_s).uniq
100
+ invalid = paths.select { |path| unsafe_relative_path?(path) }
101
+ return check("Configured paths", :error, "must be safe relative paths: #{invalid.join(", ")}") if invalid.any?
102
+
103
+ existing, missing = paths.partition { |path| configuration.app_root_path.join(path).directory? }
104
+ message = "#{existing.size}/#{paths.size} exist"
105
+ message = "#{message}; absent paths are skipped: #{missing.join(", ")}" if missing.any?
106
+ status = existing.empty? ? :warning : :ok
107
+ check("Configured paths", status, message)
108
+ end
109
+
110
+ def unsafe_relative_path?(value)
111
+ return true if value.empty?
112
+
113
+ path = Pathname.new(value)
114
+ path.absolute? || path.cleanpath.each_filename.first == ".."
115
+ rescue ArgumentError
116
+ true
117
+ end
118
+
119
+ def check(name, status, message)
120
+ Check.new(name: name, status: status, message: message)
121
+ end
122
+ end
123
+ end
@@ -9,6 +9,13 @@ module RailsWayback
9
9
  class Engine < ::Rails::Engine
10
10
  isolate_namespace RailsWayback
11
11
 
12
+ # Activation is scoped to a Rails server session. The marker still lets
13
+ # the standalone CLI toggle a running server, but a later server boot
14
+ # always starts with Wayback disabled.
15
+ server do
16
+ RailsWayback.toggle.reset!
17
+ end
18
+
12
19
  initializer "rails_wayback.controller_extensions" do
13
20
  ActiveSupport.on_load(:action_controller_base) do
14
21
  include RailsWayback::ControllerExtensions
@@ -20,25 +27,26 @@ module RailsWayback
20
27
  end
21
28
 
22
29
  initializer "rails_wayback.render_instrumentation" do
23
- ActiveSupport::Notifications.subscribe("render_template.action_view") do |*args|
24
- event = ActiveSupport::Notifications::Event.new(*args)
25
- identifier = event.payload[:identifier].to_s
26
- next if identifier.empty?
27
-
28
- origin = if identifier.include?("/tmp/rails_wayback/refs/")
29
- "SANDBOX"
30
- elsif identifier.start_with?("/")
31
- "CURRENT"
32
- else
33
- "OTHER"
34
- end
35
- Rails.logger.info("[rails-wayback] render #{origin}: #{identifier}") if Rails.respond_to?(:logger)
36
-
37
- # Per-request tracker so the middleware can report, in the bar,
38
- # which templates the current page actually pulled from the
39
- # sandbox. Middleware clears this at the start of every request.
40
- tracker = Thread.current[RailsWayback::RENDER_TRACKER_KEY] ||= []
41
- tracker << identifier
30
+ RailsWayback::RENDER_EVENTS.each do |event_name|
31
+ ActiveSupport::Notifications.subscribe(event_name) do |*args|
32
+ next unless RailsWayback.enabled?
33
+
34
+ event = ActiveSupport::Notifications::Event.new(*args)
35
+ identifier = event.payload[:identifier].to_s
36
+ next if identifier.empty?
37
+
38
+ origin = RailsWayback::RenderProvenance.origin_for(identifier)
39
+ Rails.logger&.info(
40
+ "[rails-wayback] #{event.name} #{origin.to_s.upcase}: #{identifier}"
41
+ )
42
+
43
+ # Notifications outside the middleware's request scope are logged
44
+ # but deliberately excluded from toolbar provenance.
45
+ RailsWayback::RenderContext.current&.record(
46
+ event: event.name,
47
+ identifier: identifier
48
+ )
49
+ end
42
50
  end
43
51
  end
44
52
 
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsWayback
4
+ module EngineMount
5
+ module_function
6
+
7
+ def path
8
+ RailsWayback::Engine.routes.default_url_options[:script_name] ||
9
+ RailsWayback::Engine.routes.find_script_name({}) ||
10
+ "/rails-wayback"
11
+ rescue StandardError
12
+ "/rails-wayback"
13
+ end
14
+ end
15
+ end
@@ -9,17 +9,28 @@ module RailsWayback
9
9
  # We shell out to git instead of pulling in a dependency to keep the
10
10
  # gem light. All commands are scoped to the host app root and return
11
11
  # plain Ruby data structures.
12
+ #
13
+ # NOTE: Maybe we should need to use a git library instead of shelling out to git.
14
+ # Only if this functionality is not enough for the gem on the future.
12
15
  class Git
13
16
  Commit = Struct.new(:sha, :short_sha, :subject, :author, :date, keyword_init: true)
17
+ Reference = Struct.new(:full_name, :name, :kind, :label, keyword_init: true)
18
+ REFERENCE_PREFIXES = {
19
+ "refs/heads/" => :branch,
20
+ "refs/remotes/" => :remote,
21
+ "refs/tags/" => :tag
22
+ }.freeze
23
+ REFERENCE_KIND_ORDER = { branch: 0, remote: 1, tag: 2 }.freeze
14
24
 
15
25
  class GitError < StandardError; end
26
+ class ExecutableNotFoundError < GitError; end
16
27
 
17
28
  def initialize(root: nil)
18
29
  @root = Pathname.new(root || RailsWayback.configuration.app_root_path)
19
30
  end
20
31
 
21
32
  def repository?
22
- Dir.exist?(@root.join(".git")) || run("rev-parse", "--is-inside-work-tree").strip == "true"
33
+ run("rev-parse", "--is-inside-work-tree").strip == "true"
23
34
  rescue GitError
24
35
  false
25
36
  end
@@ -37,6 +48,44 @@ module RailsWayback
37
48
  output.split("\n").map(&:strip).reject(&:empty?)
38
49
  end
39
50
 
51
+ # Lists refs that are already present in the local repository and match
52
+ # the same trust patterns used to authorize historical rendering. This
53
+ # never fetches or mutates Git state.
54
+ def references(patterns: RailsWayback.configuration.trusted_ref_patterns)
55
+ output = run(
56
+ "for-each-ref",
57
+ "--format=%(refname)%09%(symref)",
58
+ "refs/heads/",
59
+ "refs/remotes/",
60
+ "refs/tags/"
61
+ )
62
+
63
+ discovered = output.split("\n").filter_map do |line|
64
+ full_name, symbolic_target = line.split("\t", 2)
65
+ next unless symbolic_target.to_s.empty?
66
+ next unless trusted_reference?(full_name, patterns)
67
+
68
+ build_reference(full_name)
69
+ end
70
+ discovered.sort_by { |reference| [REFERENCE_KIND_ORDER.fetch(reference.kind), reference.label] }
71
+ end
72
+
73
+ # Resolves either a canonical full ref name (preferred) or a legacy short
74
+ # branch name, but only from the configured trusted discovery set.
75
+ def reference(selector, patterns: RailsWayback.configuration.trusted_ref_patterns)
76
+ available = references(patterns: patterns)
77
+ input = selector.to_s
78
+ exact = available.find { |candidate| candidate.full_name == input }
79
+ return exact if exact
80
+
81
+ compatible = available.find do |candidate|
82
+ candidate.name == input || candidate.label == input
83
+ end
84
+ return compatible if compatible
85
+
86
+ raise GitError, "Git ref #{input.inspect} is not trusted or is unavailable locally"
87
+ end
88
+
40
89
  def commits(branch, limit: RailsWayback.configuration.max_commits)
41
90
  output = run(
42
91
  "log",
@@ -53,6 +102,8 @@ module RailsWayback
53
102
 
54
103
  def resolve_ref(ref)
55
104
  run("rev-parse", "--verify", "#{ref}^{commit}").strip
105
+ rescue ExecutableNotFoundError
106
+ raise
56
107
  rescue GitError
57
108
  raise RefNotFoundError, "Unknown git ref: #{ref.inspect}"
58
109
  end
@@ -61,6 +112,14 @@ module RailsWayback
61
112
  run("show", "#{ref}:#{path}")
62
113
  end
63
114
 
115
+ def tree?(ref, path)
116
+ run("cat-file", "-t", "#{ref}:#{path}").strip == "tree"
117
+ rescue ExecutableNotFoundError
118
+ raise
119
+ rescue GitError
120
+ false
121
+ end
122
+
64
123
  # Returns the local branch names that contain `sha` in their history.
65
124
  # Used by the bar to reflect the branch you are RENDERING with when
66
125
  # you travel, instead of the branch that is checked out on disk
@@ -72,6 +131,11 @@ module RailsWayback
72
131
  []
73
132
  end
74
133
 
134
+ def refs_containing(sha)
135
+ output = run("for-each-ref", "--contains=#{sha}", "--format=%(refname)")
136
+ output.split("\n").map(&:strip).reject(&:empty?)
137
+ end
138
+
75
139
  # Same as `branches_containing`, but picks the most contextual one
76
140
  # for display in the bar when the developer did NOT provide an
77
141
  # explicit branch alongside the sha:
@@ -121,20 +185,40 @@ module RailsWayback
121
185
  FileUtils.rm_f(target.join(".rails_wayback_index")) if target
122
186
  end
123
187
 
124
- def root
125
- @root
126
- end
188
+ attr_reader :root
127
189
 
128
190
  private
129
191
 
130
- def run(*args)
131
- run_with_env({}, *args)
192
+ def trusted_reference?(full_name, patterns)
193
+ Array(patterns).map(&:to_s).any? { |pattern| File.fnmatch?(pattern, full_name) }
132
194
  end
133
195
 
134
- def run_with_env(env, *args)
135
- stdout, stderr, status = Open3.capture3(env, "git", "-C", @root.to_s, *args)
196
+ def build_reference(full_name)
197
+ prefix, kind = REFERENCE_PREFIXES.find { |candidate, _value| full_name.start_with?(candidate) }
198
+ return unless prefix
199
+
200
+ name = full_name.delete_prefix(prefix)
201
+ label = kind == :tag ? "tag:#{name}" : name
202
+ Reference.new(full_name: full_name, name: name, kind: kind, label: label)
203
+ end
204
+
205
+ def run(*)
206
+ run_with_env({}, *)
207
+ end
208
+
209
+ def run_with_env(env, *)
210
+ stdout, stderr, status = Open3.capture3(env, "git", "-C", @root.to_s, *)
136
211
  raise GitError, stderr.strip unless status.success?
212
+
137
213
  stdout
214
+ rescue Errno::ENOENT
215
+ raise ExecutableNotFoundError,
216
+ "Required executable `git` was not found in PATH. " \
217
+ "Run `bundle exec rails-wayback doctor` for diagnostics."
218
+ rescue SystemCallError => e
219
+ raise GitError,
220
+ "Could not execute required executable `git`: #{e.message}. " \
221
+ "Run `bundle exec rails-wayback doctor` for diagnostics."
138
222
  end
139
223
  end
140
224
  end