copse 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 +7 -0
- data/CHANGELOG.md +55 -0
- data/LICENSE.txt +21 -0
- data/README.md +220 -0
- data/lib/copse/database.rb +106 -0
- data/lib/copse/procfile.rb +254 -0
- data/lib/copse/railtie.rb +72 -0
- data/lib/copse/session.rb +558 -0
- data/lib/copse/url_options.rb +38 -0
- data/lib/copse/version.rb +5 -0
- data/lib/copse/worktree.rb +133 -0
- data/lib/copse.rb +102 -0
- data/lib/generators/copse/install_generator.rb +142 -0
- data/lib/generators/copse/templates/Procfile.dev.tt +1 -0
- data/lib/generators/copse/templates/dev.tt +7 -0
- metadata +63 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module Copse
|
|
6
|
+
# Answers "what am I called, and what port do I get" by shelling out to git.
|
|
7
|
+
#
|
|
8
|
+
# Holds no state and touches no files. Nothing here needs git to succeed --
|
|
9
|
+
# git only answers the naming question more precisely. A plain directory, or a
|
|
10
|
+
# machine with no git at all, degrades to the directory name.
|
|
11
|
+
class Worktree
|
|
12
|
+
LABEL_LIMIT = 63
|
|
13
|
+
|
|
14
|
+
attr_reader :root
|
|
15
|
+
|
|
16
|
+
def initialize(root = Dir.pwd)
|
|
17
|
+
@root = File.expand_path(root)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# `<project>.localhost` for a main worktree, `<branch>.<project>.localhost`
|
|
21
|
+
# for a linked one.
|
|
22
|
+
def host
|
|
23
|
+
@host ||= [slug, project, "localhost"].compact.join(".")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def port
|
|
27
|
+
@port ||= Copse.port_for(host)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def companion_port
|
|
31
|
+
@companion_port ||= Copse.companion_port_for(host)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def url
|
|
35
|
+
"http://#{host}:#{port}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# What a linked worktree's development database name is suffixed with, or nil
|
|
39
|
+
# in a main worktree -- which keeps the app's plain database.
|
|
40
|
+
#
|
|
41
|
+
# The same slug the hostname uses, with `-` swapped for `_`: a hyphen is
|
|
42
|
+
# legal in a database name but has to be quoted on every `psql` and `mysql`
|
|
43
|
+
# command line, and `_` is what Rails' own `<app>_development` already uses.
|
|
44
|
+
def database_suffix
|
|
45
|
+
slug&.tr("-", "_")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The project name: the main worktree's directory name, even when called from
|
|
49
|
+
# a linked worktree.
|
|
50
|
+
def project
|
|
51
|
+
@project ||= self.class.slug(File.basename(main_root)) || "app"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# nil for a main worktree; the branch name (or this directory's name on a
|
|
55
|
+
# detached HEAD) for a linked one.
|
|
56
|
+
def slug
|
|
57
|
+
return @slug if defined?(@slug)
|
|
58
|
+
|
|
59
|
+
@slug = if linked?
|
|
60
|
+
self.class.slug(branch) || self.class.slug(File.basename(toplevel))
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def linked?
|
|
65
|
+
return @linked if defined?(@linked)
|
|
66
|
+
|
|
67
|
+
@linked = !main_root.nil? && !toplevel.nil? && main_root != toplevel
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def branch
|
|
71
|
+
return @branch if defined?(@branch)
|
|
72
|
+
|
|
73
|
+
name = git("rev-parse", "--abbrev-ref", "HEAD")
|
|
74
|
+
# "HEAD" means detached; there is no branch to name the worktree after.
|
|
75
|
+
@branch = (name == "HEAD" ? nil : name)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Reduces an arbitrary string to a single valid DNS label.
|
|
79
|
+
#
|
|
80
|
+
# A whitelist, not a substitution list. Git accepts branch names containing
|
|
81
|
+
# `;`, `$(`, `&&`, quotes, and more, and the slug flows into COPSE_HOST,
|
|
82
|
+
# COPSE_URL, every child process's environment, and a generated Procfile that
|
|
83
|
+
# a shell will execute -- so anything outside [a-z0-9-] is replaced rather
|
|
84
|
+
# than passed through. Branch names also arrive from collaborators via
|
|
85
|
+
# `git fetch`; they are not necessarily the developer's own input.
|
|
86
|
+
#
|
|
87
|
+
# Returns nil when nothing survives, so callers can fall back.
|
|
88
|
+
def self.slug(value)
|
|
89
|
+
collapsed = value.to_s.downcase.gsub(/[^a-z0-9]+/, "-")
|
|
90
|
+
trimmed = collapsed[0, LABEL_LIMIT].to_s.gsub(/\A-+|-+\z/, "")
|
|
91
|
+
trimmed.empty? ? nil : trimmed
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def toplevel
|
|
97
|
+
return @toplevel if defined?(@toplevel)
|
|
98
|
+
|
|
99
|
+
path = git("rev-parse", "--show-toplevel")
|
|
100
|
+
@toplevel = path && File.realpath(path)
|
|
101
|
+
rescue Errno::ENOENT
|
|
102
|
+
@toplevel = nil
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# The main worktree's root. `--git-common-dir` points at the shared `.git`
|
|
106
|
+
# directory from anywhere in the repository, including a linked worktree, so
|
|
107
|
+
# its parent is the main checkout.
|
|
108
|
+
def main_root
|
|
109
|
+
return @main_root if defined?(@main_root)
|
|
110
|
+
|
|
111
|
+
common = git("rev-parse", "--git-common-dir")
|
|
112
|
+
@main_root =
|
|
113
|
+
if common
|
|
114
|
+
File.realpath(File.dirname(File.expand_path(common, @root)))
|
|
115
|
+
else
|
|
116
|
+
# Not a git worktree, or no git: fall back to this directory's name.
|
|
117
|
+
@root
|
|
118
|
+
end
|
|
119
|
+
rescue Errno::ENOENT
|
|
120
|
+
@main_root = @root
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Returns the trimmed stdout, or nil when git fails for any reason --
|
|
124
|
+
# including not being installed.
|
|
125
|
+
def git(*args)
|
|
126
|
+
out, _err, status = Open3.capture3("git", "-C", @root, *args)
|
|
127
|
+
status.success? ? out.strip : nil
|
|
128
|
+
rescue Errno::ENOENT, Errno::EACCES
|
|
129
|
+
# No git on PATH. Not an error: derivation does not require it.
|
|
130
|
+
nil
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
data/lib/copse.rb
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "zlib"
|
|
4
|
+
|
|
5
|
+
require_relative "copse/version"
|
|
6
|
+
require_relative "copse/url_options"
|
|
7
|
+
require_relative "copse/worktree"
|
|
8
|
+
require_relative "copse/database"
|
|
9
|
+
require_relative "copse/procfile"
|
|
10
|
+
require_relative "copse/session"
|
|
11
|
+
|
|
12
|
+
# Copse gives every Rails app and every git worktree its own hostname and its
|
|
13
|
+
# own port, derived rather than assigned, so nothing collides and nothing has to
|
|
14
|
+
# be chosen.
|
|
15
|
+
module Copse
|
|
16
|
+
# The port space derivation draws from.
|
|
17
|
+
PORT_RANGE = (3000..9999).freeze
|
|
18
|
+
|
|
19
|
+
# Well-known service ports inside PORT_RANGE. These are excluded from the
|
|
20
|
+
# available set rather than probed around, so derivation stays a pure function
|
|
21
|
+
# of the hostname -- a reserved port is unreachable by construction.
|
|
22
|
+
#
|
|
23
|
+
# Changing this list shifts every index in AVAILABLE_PORTS above the change and
|
|
24
|
+
# therefore moves most derived ports. That is a breaking change to the promise
|
|
25
|
+
# that teammates derive the same port (R4); it requires a major version bump.
|
|
26
|
+
RESERVED_PORTS = [
|
|
27
|
+
3036, # vite_ruby dev server default
|
|
28
|
+
3306, # MySQL
|
|
29
|
+
3389, # RDP
|
|
30
|
+
4200, # Angular dev server
|
|
31
|
+
4369, # Erlang EPMD
|
|
32
|
+
5000, # macOS AirPlay Receiver, Flask
|
|
33
|
+
5060, # SIP
|
|
34
|
+
5173, # Vite default
|
|
35
|
+
5432, # PostgreSQL
|
|
36
|
+
5601, # Kibana
|
|
37
|
+
5672, # RabbitMQ
|
|
38
|
+
5900, # VNC
|
|
39
|
+
6379, # Redis
|
|
40
|
+
6432, # PgBouncer
|
|
41
|
+
7000, # macOS AirPlay Receiver, Cassandra
|
|
42
|
+
8000, # common alternate HTTP
|
|
43
|
+
8025, # Mailhog / Mailpit web UI
|
|
44
|
+
8080, # common alternate HTTP
|
|
45
|
+
8443, # common alternate HTTPS
|
|
46
|
+
8500, # Consul
|
|
47
|
+
8888, # Jupyter
|
|
48
|
+
9000, # PHP-FPM, SonarQube
|
|
49
|
+
9092, # Kafka
|
|
50
|
+
9200, # Elasticsearch
|
|
51
|
+
9418 # git protocol
|
|
52
|
+
].freeze
|
|
53
|
+
|
|
54
|
+
# The ordered set derivation indexes into. Frozen at load: there is no
|
|
55
|
+
# configuration accessor, so there is no memoized state that can go stale.
|
|
56
|
+
AVAILABLE_PORTS = (PORT_RANGE.to_a - RESERVED_PORTS).freeze
|
|
57
|
+
|
|
58
|
+
# The supervisors `bin/dev` can be generated against. Only the supervisor
|
|
59
|
+
# differs: the derivation is a pure function of the worktree and knows nothing
|
|
60
|
+
# about either one.
|
|
61
|
+
PROCESS_MANAGERS = %w[foreman overmind].freeze
|
|
62
|
+
|
|
63
|
+
# Boots the app. This is what `bin/dev` calls.
|
|
64
|
+
#
|
|
65
|
+
# Returns the exit status of the foreground process so `bin/dev` can propagate
|
|
66
|
+
# it, which matters for shell `&&` chains and scripted use.
|
|
67
|
+
#
|
|
68
|
+
# `process_manager: :overmind` hands the whole Procfile to Overmind instead,
|
|
69
|
+
# replacing this process, and `args` is forwarded to it (`bin/dev -l web`).
|
|
70
|
+
def self.start(root: Dir.pwd, process_manager: :foreman, args: [])
|
|
71
|
+
session = Session.new(Worktree.new(root))
|
|
72
|
+
|
|
73
|
+
# `exec_overmind` never returns, so falling through to the foreman session
|
|
74
|
+
# means overmind is not installed *on this machine*. That is a fallback rather
|
|
75
|
+
# than an error on purpose: `bin/dev` is committed, and a teammate without
|
|
76
|
+
# overmind should still boot.
|
|
77
|
+
session.exec_overmind(args) if process_manager.to_s == "overmind" && session.overmind_available?
|
|
78
|
+
|
|
79
|
+
session.start
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# The derived port for a hostname. A pure function: same hostname, same port,
|
|
83
|
+
# on every machine, in every terminal, across reboots and Ruby versions.
|
|
84
|
+
def self.port_for(hostname)
|
|
85
|
+
AVAILABLE_PORTS.fetch(Zlib.crc32(hostname) % AVAILABLE_PORTS.size)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# A second port derived from the same hostname, for a JS bundler's own dev
|
|
89
|
+
# server. Salting keeps it stable (R4) and inside the available set (so it can
|
|
90
|
+
# never land on a reserved service port) while guaranteeing it differs from the
|
|
91
|
+
# primary port for that same hostname.
|
|
92
|
+
def self.companion_port_for(hostname)
|
|
93
|
+
primary = port_for(hostname)
|
|
94
|
+
index = Zlib.crc32("vite:#{hostname}") % AVAILABLE_PORTS.size
|
|
95
|
+
candidate = AVAILABLE_PORTS.fetch(index)
|
|
96
|
+
return candidate unless candidate == primary
|
|
97
|
+
|
|
98
|
+
AVAILABLE_PORTS.fetch((index + 1) % AVAILABLE_PORTS.size)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
require_relative "copse/railtie" if defined?(Rails::Railtie)
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators/base"
|
|
4
|
+
|
|
5
|
+
require "copse"
|
|
6
|
+
|
|
7
|
+
module Copse
|
|
8
|
+
module Generators
|
|
9
|
+
# `bin/rails generate copse:install`
|
|
10
|
+
#
|
|
11
|
+
# Wires an app to Copse the way `tailwindcss:install` wires up Tailwind: the
|
|
12
|
+
# app commits a `bin/dev` the whole team shares.
|
|
13
|
+
class InstallGenerator < ::Rails::Generators::Base
|
|
14
|
+
BIN_DEV = "bin/dev"
|
|
15
|
+
PROCFILE = "Procfile.dev"
|
|
16
|
+
MARKER = "Copse.start"
|
|
17
|
+
OVERMIND_MARKER = "process_manager: :overmind"
|
|
18
|
+
|
|
19
|
+
source_root File.expand_path("templates", __dir__)
|
|
20
|
+
|
|
21
|
+
class_option :process_manager, type: :string, default: nil,
|
|
22
|
+
desc: "Supervisor bin/dev uses: #{Copse::PROCESS_MANAGERS.join(' or ')}"
|
|
23
|
+
|
|
24
|
+
def create_bin_dev
|
|
25
|
+
# Resolved before anything is written: detection reads the very bin/dev
|
|
26
|
+
# this method is about to overwrite.
|
|
27
|
+
process_manager
|
|
28
|
+
|
|
29
|
+
if exists?(BIN_DEV)
|
|
30
|
+
existing = File.read(absolute(BIN_DEV))
|
|
31
|
+
|
|
32
|
+
if existing.include?(MARKER)
|
|
33
|
+
if existing.include?(OVERMIND_MARKER) == overmind?
|
|
34
|
+
# Already wired to this supervisor. Running the generator twice is a
|
|
35
|
+
# no-op.
|
|
36
|
+
say_status :identical, BIN_DEV, :blue
|
|
37
|
+
return
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Same wiring, other supervisor. Switching is the whole point of the
|
|
41
|
+
# flag, and a Copse bin/dev holds nothing of the app's own to preserve,
|
|
42
|
+
# so this one is rewritten without a backup.
|
|
43
|
+
say_status :force, "#{BIN_DEV} (#{process_manager})", :yellow
|
|
44
|
+
else
|
|
45
|
+
# Teams keep real setup logic in bin/dev -- dependency checks, database
|
|
46
|
+
# bootstrapping. Replacing it without a copy would destroy that in one
|
|
47
|
+
# command with nothing to recover from, and prompting is not an option
|
|
48
|
+
# in a scripted run.
|
|
49
|
+
backup = "#{BIN_DEV}.before-copse"
|
|
50
|
+
FileUtils.cp(absolute(BIN_DEV), absolute(backup))
|
|
51
|
+
say_status :backup, backup, :yellow
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
template "dev.tt", BIN_DEV, force: true
|
|
56
|
+
chmod BIN_DEV, 0o755
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def create_procfile_dev
|
|
60
|
+
if exists?(PROCFILE)
|
|
61
|
+
# R10: never overwritten, and never silently. Whatever the app already
|
|
62
|
+
# runs -- a tailwindcss:watch line, a vite line -- is left exactly as is.
|
|
63
|
+
say_status :skip, "#{PROCFILE} already exists, leaving it untouched", :yellow
|
|
64
|
+
return
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
template "Procfile.dev.tt", PROCFILE
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def report_process_manager
|
|
71
|
+
return unless File.exist?(absolute(PROCFILE))
|
|
72
|
+
|
|
73
|
+
secondaries = Copse::Procfile.parse(File.read(absolute(PROCFILE))).secondaries
|
|
74
|
+
|
|
75
|
+
if overmind?
|
|
76
|
+
say ""
|
|
77
|
+
say "bin/dev hands all of Procfile.dev to overmind; attach with `overmind connect web`."
|
|
78
|
+
say "Keep `web` first in Procfile.dev: overmind gives each process base + index * 100."
|
|
79
|
+
say "Without overmind installed, bin/dev falls back to the foreman session."
|
|
80
|
+
# The fallback is not hypothetical -- overmind is probed per machine, so a
|
|
81
|
+
# teammate without it lands on foreman and needs it for these entries.
|
|
82
|
+
say "Which needs foreman, so keep `gem \"foreman\"` available too." if secondaries.any?
|
|
83
|
+
return
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
return if secondaries.empty?
|
|
87
|
+
|
|
88
|
+
say ""
|
|
89
|
+
say "Copse runs the `web` process in the foreground and hands the rest to foreman."
|
|
90
|
+
say "Add `gem \"foreman\"` to your Gemfile, or install it for the Ruby you use here."
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
# foreman unless asked otherwise -- with one exception. An app whose bin/dev
|
|
96
|
+
# already drives overmind gets the overmind variant by default, because the
|
|
97
|
+
# alternative is what this generator used to do: force-overwrite a working
|
|
98
|
+
# overmind setup with one that drops back to foreman.
|
|
99
|
+
def process_manager
|
|
100
|
+
@process_manager ||= begin
|
|
101
|
+
requested = options[:process_manager]
|
|
102
|
+
|
|
103
|
+
if requested.nil?
|
|
104
|
+
existing_overmind? ? "overmind" : "foreman"
|
|
105
|
+
else
|
|
106
|
+
unless Copse::PROCESS_MANAGERS.include?(requested)
|
|
107
|
+
raise Thor::Error, "--process-manager must be one of: #{Copse::PROCESS_MANAGERS.join(', ')}"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
requested
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def overmind? = process_manager == "overmind"
|
|
116
|
+
|
|
117
|
+
# Detection looks for a bin/dev that *runs* overmind, not one that merely says
|
|
118
|
+
# the word: `# migrated off overmind` in a comment of a foreman script must
|
|
119
|
+
# not flip the supervisor. Either the Copse marker, or an `overmind start`
|
|
120
|
+
# invocation (`s` is overmind's own alias for it).
|
|
121
|
+
OVERMIND_INVOCATION = /\bovermind\s+s(?:tart)?\b/
|
|
122
|
+
|
|
123
|
+
def existing_overmind?
|
|
124
|
+
return false unless exists?(BIN_DEV)
|
|
125
|
+
|
|
126
|
+
contents = File.read(absolute(BIN_DEV))
|
|
127
|
+
contents.include?(OVERMIND_MARKER) || contents.match?(OVERMIND_INVOCATION)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Interpolated into the bin/dev template. ARGV is forwarded only on the
|
|
131
|
+
# overmind path, where it has somewhere to go (`bin/dev -l web`); the foreman
|
|
132
|
+
# session takes no arguments.
|
|
133
|
+
def copse_start_arguments
|
|
134
|
+
overmind? ? "(#{OVERMIND_MARKER}, args: ARGV)" : ""
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def exists?(relative) = File.exist?(absolute(relative))
|
|
138
|
+
|
|
139
|
+
def absolute(relative) = File.join(destination_root, relative)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
web: bin/rails server
|
metadata
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: copse
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Kieran Klaassen
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-07-24 00:00:00.000000000 Z
|
|
12
|
+
dependencies: []
|
|
13
|
+
description: Copse derives a hostname and a port for each Rails app and each git worktree,
|
|
14
|
+
so several can run at once without colliding and without anyone choosing numbers.
|
|
15
|
+
The web process stays in the foreground and owns the TTY, so binding.irb and debug
|
|
16
|
+
keep working. No daemon, no reverse proxy, no privileged listener, and no runtime
|
|
17
|
+
dependencies.
|
|
18
|
+
email:
|
|
19
|
+
executables: []
|
|
20
|
+
extensions: []
|
|
21
|
+
extra_rdoc_files: []
|
|
22
|
+
files:
|
|
23
|
+
- CHANGELOG.md
|
|
24
|
+
- LICENSE.txt
|
|
25
|
+
- README.md
|
|
26
|
+
- lib/copse.rb
|
|
27
|
+
- lib/copse/database.rb
|
|
28
|
+
- lib/copse/procfile.rb
|
|
29
|
+
- lib/copse/railtie.rb
|
|
30
|
+
- lib/copse/session.rb
|
|
31
|
+
- lib/copse/url_options.rb
|
|
32
|
+
- lib/copse/version.rb
|
|
33
|
+
- lib/copse/worktree.rb
|
|
34
|
+
- lib/generators/copse/install_generator.rb
|
|
35
|
+
- lib/generators/copse/templates/Procfile.dev.tt
|
|
36
|
+
- lib/generators/copse/templates/dev.tt
|
|
37
|
+
homepage: https://github.com/kieranklaassen/copse
|
|
38
|
+
licenses:
|
|
39
|
+
- MIT
|
|
40
|
+
metadata:
|
|
41
|
+
homepage_uri: https://github.com/kieranklaassen/copse
|
|
42
|
+
source_code_uri: https://github.com/kieranklaassen/copse
|
|
43
|
+
changelog_uri: https://github.com/kieranklaassen/copse/blob/main/CHANGELOG.md
|
|
44
|
+
post_install_message:
|
|
45
|
+
rdoc_options: []
|
|
46
|
+
require_paths:
|
|
47
|
+
- lib
|
|
48
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
49
|
+
requirements:
|
|
50
|
+
- - ">="
|
|
51
|
+
- !ruby/object:Gem::Version
|
|
52
|
+
version: 3.2.0
|
|
53
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
54
|
+
requirements:
|
|
55
|
+
- - ">="
|
|
56
|
+
- !ruby/object:Gem::Version
|
|
57
|
+
version: '0'
|
|
58
|
+
requirements: []
|
|
59
|
+
rubygems_version: 3.4.10
|
|
60
|
+
signing_key:
|
|
61
|
+
specification_version: 4
|
|
62
|
+
summary: A hostname and a port for every Rails app and every git worktree.
|
|
63
|
+
test_files: []
|