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.
@@ -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
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "copse"
6
+
7
+ exit Copse.start<%= copse_start_arguments %>
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: []