branch_db 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: c68b9335eb0602387a79af5a1e9c715249e2e30070faaa916510ff8e97f21203
4
+ data.tar.gz: b7f409fd18171d5845e812f1b5a1b2ec052b02b46d809c5b64a611bb5ee3a966
5
+ SHA512:
6
+ metadata.gz: 6ef2f12ea030b88a18549ab83d9b8aac9db5afae73ab607165d5667e789d86af0292b0a4c7b64cad56c828120c10da5a7d538529e8afd4c609748a096a70e903
7
+ data.tar.gz: 2314b2ab78c68af51f995ffc9d2600dbc13ed3a78a2c504661396e243fed6e6bd24326841ea6de6ada234adc01b72fbb9b67523fe99322bc51a4e9ea21eef94f
data/CHANGELOG.md ADDED
@@ -0,0 +1,22 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2026-01-18
4
+
5
+ ### Added
6
+
7
+ - Automatic per-branch PostgreSQL database management for Rails
8
+ - Seamless integration with Rails `db:prepare` task
9
+ - Automatic cloning from parent branch database (with main as fallback) when creating new branch databases
10
+ - Smart parent branch detection via git reflog analysis
11
+ - `BRANCH_DB_PARENT` environment variable to override parent branch detection
12
+ - Development-only cloning (test databases use standard Rails schema load)
13
+ - `BranchDb.database_name` helper for dynamic database naming in `database.yml`
14
+ - `rails db:branch:list` task to list all branch databases
15
+ - `rails db:branch:purge` task to remove all branch databases (keeps main and current)
16
+ - `rails db:branch:prune` task to remove databases for deleted git branches
17
+ - Support for Rails multiple database configurations
18
+ - Configurable main branch name (default: `main`)
19
+ - Configurable branch name length limit (default: 33 characters)
20
+ - Configurable database suffixes (`development_suffix`, `test_suffix`)
21
+ - Active connection detection to prevent dropping databases in use
22
+ - Rails generator for easy installation (`rails generate branch_db:install`)
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 MilkStraw AI
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 all
13
+ 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 THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,435 @@
1
+ # BranchDb
2
+
3
+ [![Gem Version](https://badge.fury.io/rb/branch_db.svg)](https://badge.fury.io/rb/branch_db)
4
+ [![Build Status](https://github.com/milkstrawai/branch_db/actions/workflows/main.yml/badge.svg)](https://github.com/milkstrawai/branch_db/actions)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
6
+
7
+ **Automatic per-branch PostgreSQL databases for Rails development.**
8
+
9
+ BranchDb eliminates database migration conflicts by giving each git branch its own isolated database. Switch branches freely without worrying about schema mismatches or losing development data.
10
+
11
+ ## Table of Contents
12
+
13
+ - [The Problem](#the-problem)
14
+ - [The Solution](#the-solution)
15
+ - [Installation](#installation)
16
+ - [Configuration](#configuration)
17
+ - [Usage](#usage)
18
+ - [How It Works](#how-it-works)
19
+ - [Requirements](#requirements)
20
+ - [Important Notes](#important-notes)
21
+ - [Troubleshooting](#troubleshooting)
22
+ - [Development](#development)
23
+ - [Roadmap](#roadmap)
24
+ - [Contributing](#contributing)
25
+ - [License](#license)
26
+
27
+ ## The Problem
28
+
29
+ Working on multiple feature branches with different migrations causes pain:
30
+
31
+ ```
32
+ # On feature-a branch: Add a 'status' column
33
+ rails generate migration AddStatusToUsers status:string
34
+ rails db:migrate
35
+
36
+ # Switch to feature-b branch
37
+ git checkout feature-b
38
+ git status
39
+ # => modified: db/schema.rb <- Contains 'status' column from feature-a!
40
+
41
+ # Now your schema.rb has changes that don't belong to this branch
42
+ # Accidentally commit it? You've just mixed schema changes across branches
43
+ # Run db:migrate? Schema.rb still shows the foreign column
44
+ ```
45
+
46
+ ## The Solution
47
+
48
+ BranchDb automatically manages separate databases for each branch:
49
+
50
+ ```
51
+ main branch → myapp_development_main
52
+ feature-auth → myapp_development_feature_auth
53
+ feature-payments → myapp_development_feature_payments
54
+ bugfix-login → myapp_development_bugfix_login
55
+ ```
56
+
57
+ Each branch has its own isolated database with its own schema and data. Switch branches, restart your server, and you're working with the right database automatically.
58
+
59
+ ## Installation
60
+
61
+ Add BranchDb to your Gemfile:
62
+
63
+ ```ruby
64
+ group :development, :test do
65
+ gem 'branch_db'
66
+ end
67
+ ```
68
+
69
+ Install and run the generator:
70
+
71
+ ```bash
72
+ bundle install
73
+ rails generate branch_db:install
74
+ ```
75
+
76
+ Update your `config/database.yml`:
77
+
78
+ ```yaml
79
+ development:
80
+ <<: *default
81
+ database: <%= BranchDb.database_name('myapp_development') %>
82
+
83
+ test:
84
+ <<: *default
85
+ database: <%= BranchDb.database_name('myapp_test') %>
86
+ ```
87
+
88
+ > **Note:** Replace `myapp` with your application name. Keep `_development` and `_test` suffixes for the cleanup feature to work correctly.
89
+
90
+ Initialize your first branch database:
91
+
92
+ ```bash
93
+ rails db:prepare
94
+ ```
95
+
96
+ ## Configuration
97
+
98
+ The generator creates `config/initializers/branch_db.rb`:
99
+
100
+ ```ruby
101
+ BranchDb.configure do |config|
102
+ # The name of your main/stable branch (default: 'main')
103
+ config.main_branch = 'main'
104
+
105
+ # Maximum length for branch name suffix (default: 33)
106
+ # PostgreSQL has a 63 character limit for database names
107
+ # Formula: base_name_length + 1 (underscore) + max_branch_length <= 63
108
+ config.max_branch_length = 33
109
+
110
+ # Database name suffixes for cleanup feature (default: '_development', '_test')
111
+ # Customize if your database names use different conventions
112
+ # config.development_suffix = '_development'
113
+ # config.test_suffix = '_test'
114
+ end
115
+ ```
116
+
117
+ ### Configuration Options
118
+
119
+ | Option | Default | Description |
120
+ |--------|---------|-------------|
121
+ | `main_branch` | `'main'` | Your primary branch name (used as fallback clone source) |
122
+ | `max_branch_length` | `33` | Max characters for branch suffix (prevents exceeding PostgreSQL's 63-char limit) |
123
+ | `development_suffix` | `'_development'` | Suffix pattern for development databases |
124
+ | `test_suffix` | `'_test'` | Suffix pattern for test databases |
125
+
126
+ ## Usage
127
+
128
+ ### Daily Workflow
129
+
130
+ BranchDb enhances Rails' built-in `db:prepare` command:
131
+
132
+ ```bash
133
+ # Just use Rails' standard command - now branch-aware!
134
+ rails db:prepare
135
+ ```
136
+
137
+ **What happens (development only):**
138
+ 1. Checks if your branch's database exists and has schema
139
+ 2. If missing/empty and parent/main exists: **clones from parent branch** (or main as fallback)
140
+ 3. If on main branch or no source exists: defers to standard Rails behavior
141
+ 4. Rails then runs pending migrations and seeds as usual
142
+
143
+ **Test databases** use standard Rails behavior (schema load, no cloning):
144
+ ```bash
145
+ RAILS_ENV=test rails db:prepare
146
+ ```
147
+
148
+ > **Note:** Cloning only runs in development environment. All commands support Rails' multiple database feature.
149
+
150
+ ### Available Commands
151
+
152
+ | Command | Description |
153
+ |---------|-------------|
154
+ | `rails db:prepare` | Rails' standard command, enhanced with cloning from parent/main |
155
+ | `rails db:branch:list` | List all branch databases |
156
+ | `rails db:branch:purge` | Remove all branch databases except current and main |
157
+ | `rails db:branch:prune` | Remove databases for branches that no longer exist in git |
158
+
159
+ ### Examples
160
+
161
+ ```bash
162
+ # Starting work on a new feature branch
163
+ git checkout -b feature-new-thing
164
+ rails db:prepare # Clones from parent branch (or main as fallback)
165
+ rails server
166
+
167
+ # Switching to another branch
168
+ git checkout feature-other-thing
169
+ # Restart your Rails server to connect to the other database
170
+ rails server
171
+
172
+ # Purging all branch databases (keeps current and main only)
173
+ rails db:branch:purge
174
+ # => Found 5 database(s) to remove:
175
+ # => - myapp_development_feature_old
176
+ # => - myapp_test_feature_old
177
+ # => ...
178
+ # => Proceed with deletion? [y/N]
179
+
180
+ # Pruning databases for deleted git branches only
181
+ rails db:branch:prune
182
+ # => Found 2 database(s) to remove:
183
+ # => - myapp_development_merged_feature
184
+ # => - myapp_test_merged_feature
185
+ # => Proceed with deletion? [y/N]
186
+ ```
187
+
188
+ ## How It Works
189
+
190
+ ### Rails Integration
191
+
192
+ BranchDb enhances Rails' `db:prepare` task by adding a prerequisite that clones from the parent branch when needed. This means:
193
+
194
+ - **Zero learning curve** - use `rails db:prepare` as usual
195
+ - **Automatic cloning** - new branch databases are cloned from their parent (or main as fallback)
196
+ - **Rails handles the rest** - migrations, seeds, and schema dumps work normally
197
+
198
+ ### Database Naming
199
+
200
+ BranchDb generates database names by combining your base name with a sanitized branch name:
201
+
202
+ ```
203
+ Base name: myapp_development
204
+ Branch: feature/user-auth
205
+ Sanitized: feature_user_auth
206
+ Result: myapp_development_feature_user_auth
207
+ ```
208
+
209
+ Branch names are sanitized: non-alphanumeric characters become underscores, and names are truncated to `max_branch_length`.
210
+
211
+ ### Cloning Process
212
+
213
+ When `db:prepare` detects a missing or empty database:
214
+
215
+ 1. **Detects** the parent branch to clone from (see below)
216
+ 2. **Checks** if the parent database exists; if not, falls back to main
217
+ 3. **If source exists:** Creates the target database and uses `pg_dump | psql` for efficient cloning
218
+ 4. **If no source:** Defers to Rails' standard `db:prepare` (loads schema, runs migrations, seeds)
219
+ 5. **On main branch:** Defers to Rails' standard `db:prepare`
220
+
221
+ ### Parent Branch Detection
222
+
223
+ BranchDb intelligently detects which branch you branched from and clones its database. This enables nested feature branch workflows:
224
+
225
+ ```
226
+ main → feature-a → feature-a-child
227
+ ```
228
+
229
+ When you create `feature-a-child` from `feature-a`, BranchDb will clone from `feature-a`'s database (if it exists), not main.
230
+
231
+ **Detection priority:**
232
+
233
+ 1. `BRANCH_DB_PARENT` environment variable (explicit override)
234
+ 2. Git reflog analysis (finds the last "checkout: moving from X to current-branch")
235
+ 3. Configured `main_branch` (fallback)
236
+
237
+ **Fallback behavior:** If the detected parent's database doesn't exist, BranchDb automatically falls back to the main branch database.
238
+
239
+ **Override with environment variable:**
240
+
241
+ ```bash
242
+ # Force cloning from main, even if on a nested feature branch
243
+ BRANCH_DB_PARENT=main rails db:prepare
244
+
245
+ # Clone from a specific branch
246
+ BRANCH_DB_PARENT=feature-other rails db:prepare
247
+ ```
248
+
249
+ ### Purge Safety
250
+
251
+ The purge command protects important databases:
252
+ - Current branch's development and test databases
253
+ - Main branch's development and test databases
254
+ - Databases with active connections (skipped with warning)
255
+
256
+ ## Requirements
257
+
258
+ - **Ruby** >= 3.2
259
+ - **Rails** >= 7.0
260
+ - **PostgreSQL** (any supported version)
261
+ - **PostgreSQL client tools** in PATH:
262
+ - `psql` - for database operations
263
+ - `pg_dump` - for cloning databases
264
+ - `dropdb` - for purge/prune operations
265
+
266
+ ### Verifying PostgreSQL Tools
267
+
268
+ ```bash
269
+ which psql pg_dump dropdb
270
+ # Should output paths for all three tools
271
+ ```
272
+
273
+ If missing, install PostgreSQL client tools:
274
+
275
+ ```bash
276
+ # macOS
277
+ brew install postgresql
278
+
279
+ # Ubuntu/Debian
280
+ sudo apt-get install postgresql-client
281
+
282
+ # Docker (add to your Dockerfile)
283
+ RUN apt-get update && apt-get install -y postgresql-client
284
+ ```
285
+
286
+ ## Important Notes
287
+
288
+ ### Server Restart Required
289
+
290
+ Database selection happens at Rails boot time (ERB in `database.yml` is evaluated once). After switching branches, **restart your Rails server** to connect to the correct database.
291
+
292
+ ```bash
293
+ git checkout other-branch
294
+ # Must restart Rails to use other-branch's database
295
+ rails server # Now connected to myapp_development_other_branch
296
+ ```
297
+
298
+ ### Detached HEAD State
299
+
300
+ In detached HEAD state (e.g., `git checkout abc123`), BranchDb cannot determine a branch name. It falls back to using the base database name without a suffix. All detached HEAD checkouts share this database.
301
+
302
+ For CI environments, ensure you checkout an actual branch:
303
+
304
+ ```bash
305
+ # CI script
306
+ git checkout $BRANCH_NAME # Not just the commit SHA
307
+ rails db:prepare
308
+ ```
309
+
310
+ ### Database Name Length
311
+
312
+ PostgreSQL limits database names to 63 characters. With default settings:
313
+ - Base name: up to 29 characters
314
+ - Underscore: 1 character
315
+ - Branch suffix: up to 33 characters
316
+
317
+ If your base name is longer, reduce `max_branch_length` accordingly.
318
+
319
+ ## Troubleshooting
320
+
321
+ ### "PostgreSQL tool 'X' not found in PATH"
322
+
323
+ Install PostgreSQL client tools (see [Requirements](#requirements)).
324
+
325
+ ### "Could not connect to Postgres on port X"
326
+
327
+ Ensure PostgreSQL is running and accessible:
328
+
329
+ ```bash
330
+ # Check if PostgreSQL is running
331
+ pg_isready -h localhost -p 5432
332
+
333
+ # For Docker users
334
+ docker ps | grep postgres
335
+ ```
336
+
337
+ ### Database not switching when I change branches
338
+
339
+ Remember to restart your Rails server after switching branches. The database name is determined at boot time.
340
+
341
+ ### Clone is slow for large databases
342
+
343
+ `pg_dump | psql` is already efficient, but for very large databases consider:
344
+ - Keeping your main branch database lean
345
+ - Using database-level compression
346
+ - Running cleanup regularly to remove old branch databases
347
+
348
+ ### Branch name too long
349
+
350
+ Long branch names are automatically truncated to `max_branch_length` (default: 33). Two branches with the same prefix might collide:
351
+
352
+ ```
353
+ feature/very-long-descriptive-name-for-auth → _feature_very_long_descriptive_na
354
+ feature/very-long-descriptive-name-for-payments → _feature_very_long_descriptive_na # Same!
355
+ ```
356
+
357
+ Use shorter branch names or increase `max_branch_length` (if your base name is short enough).
358
+
359
+ ## Development
360
+
361
+ ### Setup
362
+
363
+ ```bash
364
+ git clone https://github.com/milkstrawai/branch_db.git
365
+ cd branch_db
366
+ bin/setup
367
+ ```
368
+
369
+ ### Running Tests
370
+
371
+ ```bash
372
+ # Run test suite
373
+ bundle exec rspec
374
+
375
+ # Run with coverage report
376
+ bundle exec rspec && open coverage/index.html
377
+
378
+ # Run linter
379
+ bundle exec rubocop
380
+
381
+ # Run both
382
+ bundle exec rake
383
+ ```
384
+
385
+ ### Test Coverage
386
+
387
+ The project maintains high test coverage standards:
388
+ - Line coverage: 100%
389
+ - Branch coverage: 90%
390
+
391
+ ## Roadmap
392
+
393
+ Features we're considering for future releases:
394
+
395
+ - [ ] **SQLite and MySQL support** - Database adapter pattern for non-PostgreSQL databases
396
+ - [ ] **Standalone clone task** - `rails db:branch:clone FROM=branch-name` for manual cloning
397
+ - [ ] **Post-checkout git hook** - Auto-restart Rails server on branch switch (Doable?)
398
+ - [ ] **Database info task** - `rails db:branch:info` showing current branch, DB name, size, and parent
399
+ - [ ] **Clone progress indicator** - Visual feedback for large database clones
400
+ - [ ] **Disk usage report** - `rails db:branch:list --size` to show storage per branch
401
+
402
+ Have a feature request? [Open an issue](https://github.com/milkstrawai/branch_db/issues) to discuss it!
403
+
404
+ ## Contributing
405
+
406
+ Contributions are welcome! Here's how you can help:
407
+
408
+ 1. **Fork** the repository
409
+ 2. **Create** a feature branch (`git checkout -b feature/amazing-feature`)
410
+ 3. **Commit** your changes (`git commit -m 'Add amazing feature'`)
411
+ 4. **Push** to the branch (`git push origin feature/amazing-feature`)
412
+ 5. **Open** a Pull Request
413
+
414
+ ### Guidelines
415
+
416
+ - Write tests for new features
417
+ - Follow existing code style (RuboCop will help)
418
+ - Update documentation as needed
419
+ - Keep commits focused and atomic
420
+
421
+ ### Reporting Issues
422
+
423
+ Found a bug? Please open an issue with:
424
+ - Ruby and Rails versions
425
+ - PostgreSQL version
426
+ - Steps to reproduce
427
+ - Expected vs actual behavior
428
+
429
+ ## License
430
+
431
+ BranchDb is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
432
+
433
+ ## Acknowledgments
434
+
435
+ Inspired by the pain of database migration conflicts and the joy of isolated development environments.
data/Rakefile ADDED
@@ -0,0 +1,10 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ require "rubocop/rake_task"
7
+
8
+ RuboCop::RakeTask.new
9
+
10
+ task default: %i[spec rubocop]
@@ -0,0 +1,121 @@
1
+ require "open3"
2
+
3
+ module BranchDb
4
+ class Cleaner
5
+ include PgUtils
6
+ include Logging
7
+
8
+ attr_reader :config, :output, :input, :name
9
+
10
+ def initialize(config, output: $stdout, input: $stdin, prefix: true, name: nil)
11
+ @config = config.is_a?(Hash) ? config : config.configuration_hash
12
+ @output = output
13
+ @input = input
14
+ @prefix = prefix
15
+ @name = name
16
+ end
17
+
18
+ def list_branch_databases
19
+ dev_dbs = find_databases(dev_prefix)
20
+ test_dbs = find_databases(test_prefix)
21
+
22
+ if dev_dbs.empty? && test_dbs.empty?
23
+ log "No branch databases found#{db_label}."
24
+ return []
25
+ end
26
+
27
+ all_dbs = dev_dbs + test_dbs
28
+ log "Found #{all_dbs.size} branch database(s)#{db_label}:"
29
+ all_dbs.each { |db| log " - #{db}" }
30
+ all_dbs
31
+ end
32
+
33
+ def purge(confirm: true)
34
+ delete_databases(deletable_databases, empty_msg: "No old branch databases to purge#{db_label}.",
35
+ done_msg: "Purge complete#{db_label}!", confirm:)
36
+ end
37
+
38
+ def prune(confirm: true)
39
+ delete_databases(prunable_databases, empty_msg: "No stale branch databases to prune#{db_label}.",
40
+ done_msg: "Prune complete#{db_label}!", confirm:)
41
+ end
42
+
43
+ def protected_databases
44
+ current_dev = config[:database]
45
+ current_test = current_dev.sub(dev_prefix, test_prefix)
46
+
47
+ [
48
+ current_dev,
49
+ current_test,
50
+ "#{dev_prefix}#{BranchDb.configuration.main_branch}",
51
+ "#{test_prefix}#{BranchDb.configuration.main_branch}"
52
+ ]
53
+ end
54
+
55
+ private
56
+
57
+ def deletable_databases
58
+ all_branch_databases.reject { |db| protected_databases.include?(db) }
59
+ end
60
+
61
+ def prunable_databases
62
+ existing = BranchDb::Naming.git_branches.map { BranchDb::Naming.sanitize_branch(_1) }
63
+ all_branch_databases.reject { |db| protected_databases.include?(db) || branch_exists?(db, existing) }
64
+ end
65
+
66
+ def all_branch_databases = find_databases(dev_prefix) + find_databases(test_prefix)
67
+
68
+ def branch_exists?(db, existing_branches)
69
+ prefix = db.start_with?(test_prefix) ? test_prefix : dev_prefix
70
+ existing_branches.include?(db.sub(prefix, ""))
71
+ end
72
+
73
+ def delete_databases(to_delete, empty_msg:, done_msg:, confirm:)
74
+ return log(empty_msg) if to_delete.empty?
75
+
76
+ display_databases(to_delete)
77
+ return log("Aborted.") if confirm && !user_confirmed?
78
+
79
+ to_delete.each { |db| drop_database(db) }
80
+ log done_msg
81
+ end
82
+
83
+ def display_databases(databases)
84
+ log "Found #{databases.size} database(s) to remove:"
85
+ databases.each { log " - #{_1}" }
86
+ end
87
+
88
+ def user_confirmed? = (output.print "\nProceed with deletion? [y/N] ") || input.gets&.chomp&.downcase == "y"
89
+
90
+ def find_databases(prefix)
91
+ check_pg_tools!(:psql, :dropdb)
92
+ stdout, = Open3.capture2(pg_env, "bash", "-c", "#{list_databases_cmd} | grep ^#{prefix.shellescape}")
93
+ stdout.split("\n").map(&:strip).reject(&:empty?)
94
+ end
95
+
96
+ def drop_database(db)
97
+ active = active_connections(db)
98
+ return log "⚠️ Skipping #{db} (#{active} active connection#{"s" if active > 1})" if active.positive?
99
+
100
+ dropped = system(pg_env, "bash", "-c", "dropdb #{psql_flags} #{db.shellescape}")
101
+ log dropped ? "✅ Dropped #{db}" : "❌ Failed to drop #{db}"
102
+ end
103
+
104
+ def active_connections(db)
105
+ query = "SELECT count(*) FROM pg_stat_activity WHERE datname = '#{db.gsub("'", "''")}'"
106
+ stdout, = Open3.capture2(pg_env, "bash", "-c", "psql #{psql_flags} -d postgres -tAc \"#{query}\"")
107
+ stdout.strip.to_i
108
+ end
109
+
110
+ def db_label = name && name != "primary" ? " for #{name}" : ""
111
+
112
+ def dev_prefix = "#{base_name}_"
113
+
114
+ def test_prefix = "#{base_name.sub(BranchDb.configuration.development_suffix, BranchDb.configuration.test_suffix)}_"
115
+
116
+ def base_name
117
+ suffix = BranchDb::Naming.branch_suffix
118
+ suffix.empty? ? config[:database] : config[:database].sub(/#{Regexp.escape(suffix)}\z/, "")
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,79 @@
1
+ module BranchDb
2
+ class Cloner
3
+ include PgUtils
4
+ include Logging
5
+
6
+ attr_reader :config, :output
7
+
8
+ def initialize(config, output: $stdout)
9
+ @config = config.is_a?(Hash) ? config : config.configuration_hash
10
+ @output = output
11
+ end
12
+
13
+ def clone
14
+ log "📦 Cloning #{source_db} → #{target_db}..."
15
+ create_or_recreate_database
16
+ transfer_data
17
+ end
18
+
19
+ def source_exists?
20
+ check_pg_tools!(:psql, :pg_dump)
21
+ database_exists?(source_db)
22
+ end
23
+
24
+ def source_db
25
+ @source_db ||= determine_source_db
26
+ end
27
+
28
+ def target_db
29
+ config[:database]
30
+ end
31
+
32
+ def base_name
33
+ suffix = BranchDb::Naming.branch_suffix
34
+ return target_db if suffix.empty?
35
+
36
+ target_db.sub(/#{Regexp.escape(suffix)}\z/, "")
37
+ end
38
+
39
+ private
40
+
41
+ def determine_source_db
42
+ parent_db = BranchDb::Naming.parent_database_name(base_name)
43
+ main_db = "#{base_name}_#{BranchDb.configuration.main_branch}"
44
+
45
+ return main_db if parent_db == main_db
46
+
47
+ check_pg_tools!(:psql, :pg_dump)
48
+ database_exists?(parent_db) ? parent_db : main_db
49
+ end
50
+
51
+ def database_exists?(db_name)
52
+ check_cmd = "#{list_databases_cmd} | grep -qx #{db_name.shellescape}"
53
+ system(pg_env, "bash", "-c", check_cmd)
54
+ end
55
+
56
+ def create_or_recreate_database
57
+ ActiveRecord::Tasks::DatabaseTasks.create(config)
58
+ log_indented "Created database '#{target_db}'"
59
+ rescue ActiveRecord::DatabaseAlreadyExists
60
+ log_indented "Database '#{target_db}' already exists. Recreating..."
61
+ ActiveRecord::Tasks::DatabaseTasks.drop(config)
62
+ ActiveRecord::Tasks::DatabaseTasks.create(config)
63
+ end
64
+
65
+ def transfer_data
66
+ dump_cmd = "pg_dump #{psql_flags} --no-owner --no-acl #{source_db.shellescape}"
67
+ restore_cmd = "psql #{psql_flags} #{target_db.shellescape}"
68
+ full_command = "set -o pipefail; #{dump_cmd} | #{restore_cmd}"
69
+
70
+ log_indented "Transferring data..."
71
+
72
+ unless system(pg_env, "bash", "-c", full_command, %i[out err] => File::NULL)
73
+ raise Error, "Clone failed! Check PostgreSQL connection."
74
+ end
75
+
76
+ log "✅ Database cloned successfully!"
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,12 @@
1
+ module BranchDb
2
+ class Configuration
3
+ attr_accessor :main_branch, :max_branch_length, :development_suffix, :test_suffix
4
+
5
+ def initialize
6
+ @main_branch = "main"
7
+ @max_branch_length = 33
8
+ @development_suffix = "_development"
9
+ @test_suffix = "_test"
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,48 @@
1
+ module BranchDb
2
+ module GitUtils
3
+ def current_branch
4
+ `git symbolic-ref HEAD 2>/dev/null`.chomp.sub("refs/heads/", "")
5
+ end
6
+
7
+ def git_branches
8
+ output = `git branch --format='%(refname:short)' 2>/dev/null`
9
+ output.split("\n").map(&:strip).reject(&:empty?)
10
+ end
11
+
12
+ def parent_branch
13
+ @parent_branch ||= detect_parent_branch
14
+ end
15
+
16
+ def reset_parent_cache!
17
+ @parent_branch = nil
18
+ end
19
+
20
+ private
21
+
22
+ def detect_parent_branch
23
+ return ENV["BRANCH_DB_PARENT"] if ENV["BRANCH_DB_PARENT"]
24
+
25
+ detect_parent_from_reflog || BranchDb.configuration.main_branch
26
+ end
27
+
28
+ def detect_parent_from_reflog
29
+ current = current_branch
30
+ return nil if current.empty?
31
+
32
+ `git reflog show --format='%gs' -n 100 2>/dev/null`.each_line do |line|
33
+ parent = extract_parent_from_reflog_line(line.chomp, current)
34
+ return parent if parent
35
+ end
36
+ nil
37
+ end
38
+
39
+ def extract_parent_from_reflog_line(line, current)
40
+ return nil unless line =~ /\Acheckout: moving from (.+) to #{Regexp.escape(current)}\z/
41
+
42
+ parent = ::Regexp.last_match(1).strip
43
+ return nil if parent == current || parent =~ /\A[a-f0-9]{40}\z/
44
+
45
+ parent
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,17 @@
1
+ module BranchDb
2
+ module Logging
3
+ PREFIX = "[branch_db]".freeze
4
+
5
+ private
6
+
7
+ def log(message)
8
+ output.puts prefix? ? "#{PREFIX} #{message}" : message
9
+ end
10
+
11
+ def log_indented(message)
12
+ output.puts prefix? ? "#{PREFIX} #{message}" : " #{message}"
13
+ end
14
+
15
+ def prefix? = @prefix != false
16
+ end
17
+ end
@@ -0,0 +1,24 @@
1
+ module BranchDb
2
+ module Naming
3
+ extend GitUtils
4
+
5
+ class << self
6
+ def sanitize_branch(branch)
7
+ branch.gsub(/[^a-zA-Z0-9_]/, "_")
8
+ end
9
+
10
+ def branch_suffix
11
+ branch = sanitize_branch(current_branch)
12
+ max_length = BranchDb.configuration.max_branch_length
13
+ truncated = branch[0, max_length]
14
+ truncated.empty? ? "" : "_#{truncated}"
15
+ end
16
+
17
+ def database_name(base_name) = "#{base_name}#{branch_suffix}"
18
+
19
+ def main_database_name(base_name) = "#{base_name}_#{BranchDb.configuration.main_branch}"
20
+
21
+ def parent_database_name(base_name) = "#{base_name}_#{sanitize_branch(parent_branch)}"
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,35 @@
1
+ require "shellwords"
2
+
3
+ module BranchDb
4
+ module PgUtils
5
+ PG_TOOLS = %w[psql pg_dump dropdb].freeze
6
+
7
+ private
8
+
9
+ def psql_flags
10
+ host = config[:host].to_s.shellescape
11
+ port = config[:port].to_s.shellescape
12
+ username = config[:username].to_s.shellescape
13
+
14
+ "-h #{host} -p #{port} -U #{username}"
15
+ end
16
+
17
+ def pg_env
18
+ { "PGPASSWORD" => config[:password].to_s }
19
+ end
20
+
21
+ def list_databases_cmd
22
+ "psql #{psql_flags} -lqt | cut -d \\| -f 1 | sed 's/^[[:space:]]*//;s/[[:space:]]*$//'"
23
+ end
24
+
25
+ def check_pg_tools!(*tools)
26
+ tools = PG_TOOLS if tools.empty?
27
+
28
+ tools.each do |tool|
29
+ unless system("which #{tool} > /dev/null 2>&1")
30
+ raise Error, "PostgreSQL tool '#{tool}' not found in PATH. Please install PostgreSQL client tools."
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,58 @@
1
+ module BranchDb
2
+ # Checks if database needs initialization and triggers cloning if needed.
3
+ # Used by the db:prepare rake task enhancement.
4
+ class Preparer
5
+ include Logging
6
+
7
+ attr_reader :db_config, :output
8
+
9
+ def initialize(db_config, output: $stdout)
10
+ @db_config = db_config
11
+ @output = output
12
+ end
13
+
14
+ def prepare_if_needed
15
+ log "📦 Checking database#{db_label}..."
16
+
17
+ unless needs_cloning?
18
+ log "✅ Database '#{config[:database]}' ready."
19
+ return
20
+ end
21
+
22
+ attempt_clone
23
+ end
24
+
25
+ private
26
+
27
+ def config
28
+ db_config.configuration_hash
29
+ end
30
+
31
+ def db_label
32
+ db_config.name == "primary" ? "" : " (#{db_config.name})"
33
+ end
34
+
35
+ def needs_cloning?
36
+ establish_connection
37
+ !ActiveRecord::Base.connection.table_exists?("schema_migrations")
38
+ rescue ActiveRecord::NoDatabaseError
39
+ true
40
+ end
41
+
42
+ def establish_connection
43
+ ActiveRecord::Base.establish_connection(db_config)
44
+ end
45
+
46
+ def attempt_clone
47
+ cloner = Cloner.new(config, output:)
48
+
49
+ if cloner.target_db == cloner.source_db
50
+ log_indented "On main branch. Deferring to db:prepare..."
51
+ elsif cloner.source_exists?
52
+ cloner.clone
53
+ else
54
+ log_indented "Source database not found. Deferring to db:prepare..."
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,9 @@
1
+ module BranchDb
2
+ class Railtie < Rails::Railtie
3
+ railtie_name :branch_db
4
+
5
+ rake_tasks do
6
+ load File.expand_path("tasks/branch_db.rake", __dir__)
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,34 @@
1
+ def db_configs = ActiveRecord::Base.configurations.configs_for(env_name: Rails.env)
2
+
3
+ def cleaner_for(db_config) = BranchDb::Cleaner.new(db_config.configuration_hash, prefix: false, name: db_config.name)
4
+
5
+ namespace :db do
6
+ namespace :branch do
7
+ desc "List all branch databases"
8
+ task list: :environment do
9
+ db_configs.each { cleaner_for(_1).list_branch_databases }
10
+ end
11
+
12
+ desc "Remove all branch databases (keeps main and current branch)"
13
+ task purge: :environment do
14
+ db_configs.each { cleaner_for(_1).purge }
15
+ end
16
+
17
+ desc "Remove databases for branches that no longer exist in git"
18
+ task prune: :environment do
19
+ db_configs.each { cleaner_for(_1).prune }
20
+ end
21
+
22
+ desc "Ensure branch database exists (used by db:prepare enhancement)"
23
+ task ensure_cloned: :environment do
24
+ next unless Rails.env.development?
25
+
26
+ db_configs.each { BranchDb::Preparer.new(_1).prepare_if_needed }
27
+ rescue ActiveRecord::ConnectionNotEstablished, PG::ConnectionBad => e
28
+ abort "❌ Could not connect to Postgres: #{e.message}"
29
+ end
30
+ end
31
+ end
32
+
33
+ # Enhance Rails' db:prepare to clone from parent branch when needed
34
+ Rake::Task["db:prepare"].enhance(["db:branch:ensure_cloned"])
@@ -0,0 +1,3 @@
1
+ module BranchDb
2
+ VERSION = "0.1.0".freeze
3
+ end
data/lib/branch_db.rb ADDED
@@ -0,0 +1,32 @@
1
+ require_relative "branch_db/version"
2
+ require_relative "branch_db/configuration"
3
+ require_relative "branch_db/git_utils"
4
+ require_relative "branch_db/naming"
5
+ require_relative "branch_db/pg_utils"
6
+ require_relative "branch_db/logging"
7
+ require_relative "branch_db/cloner"
8
+ require_relative "branch_db/cleaner"
9
+ require_relative "branch_db/preparer"
10
+ require_relative "branch_db/railtie" if defined?(Rails::Railtie)
11
+
12
+ module BranchDb
13
+ class Error < StandardError; end
14
+
15
+ class << self
16
+ def configuration
17
+ @configuration ||= Configuration.new
18
+ end
19
+
20
+ def configure
21
+ yield(configuration)
22
+ end
23
+
24
+ def database_name(base_name)
25
+ Naming.database_name(base_name)
26
+ end
27
+
28
+ def main_database_name(base_name)
29
+ Naming.main_database_name(base_name)
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,43 @@
1
+ require "rails/generators/base"
2
+
3
+ module BranchDb
4
+ class InstallGenerator < Rails::Generators::Base
5
+ source_root File.expand_path("templates", __dir__)
6
+
7
+ desc "Creates a BranchDb initializer and shows setup instructions"
8
+
9
+ def create_initializer
10
+ template "initializer.rb", "config/initializers/branch_db.rb"
11
+ end
12
+
13
+ def show_instructions # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
14
+ say ""
15
+ say "=== BranchDb Installation Complete ===", :green
16
+ say ""
17
+ say "Next steps:", :yellow
18
+ say ""
19
+ say "1. Update your config/database.yml to use dynamic database names:"
20
+ say ""
21
+ say " development:"
22
+ say " database: <%= BranchDb.database_name('#{app_name}_development') %>"
23
+ say ""
24
+ say " test:"
25
+ say " database: <%= BranchDb.database_name('#{app_name}_test') %>"
26
+ say ""
27
+ say "2. Initialize your database:"
28
+ say " rails db:prepare # Creates and clones from main"
29
+ say ""
30
+ say "3. Other available tasks:"
31
+ say " rails db:branch:list # List all branch databases"
32
+ say " rails db:branch:purge # Remove all branch databases (keeps main/current)"
33
+ say " rails db:branch:prune # Remove databases for deleted git branches"
34
+ say ""
35
+ end
36
+
37
+ private
38
+
39
+ def app_name
40
+ Rails.application.class.module_parent_name.underscore
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,15 @@
1
+ BranchDb.configure do |config|
2
+ # The name of your main/stable branch (default: 'main')
3
+ # config.main_branch = 'main'
4
+
5
+ # Maximum length for branch name suffix in database names (default: 33)
6
+ # PostgreSQL has a 63 character limit for database names
7
+ # Ensure: base_name_length + 1 + max_branch_length <= 63
8
+ # config.max_branch_length = 33
9
+
10
+ # Database name suffixes for dev/test environment matching (default: '_development', '_test')
11
+ # Used by cleanup to find corresponding test databases for each dev database
12
+ # Customize if your database names use different suffixes (e.g., '_dev', '_test')
13
+ # config.development_suffix = '_development'
14
+ # config.test_suffix = '_test'
15
+ end
data/sig/branch_db.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module BranchDb
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,79 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: branch_db
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Ali Hamdi Ali Fadel
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: railties
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '7.0'
26
+ description: Creates isolated database copies for each git branch, enabling parallel
27
+ feature development without schema conflicts.
28
+ email:
29
+ - aliosm1997@gmail.com
30
+ executables: []
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - CHANGELOG.md
35
+ - LICENSE.txt
36
+ - README.md
37
+ - Rakefile
38
+ - lib/branch_db.rb
39
+ - lib/branch_db/cleaner.rb
40
+ - lib/branch_db/cloner.rb
41
+ - lib/branch_db/configuration.rb
42
+ - lib/branch_db/git_utils.rb
43
+ - lib/branch_db/logging.rb
44
+ - lib/branch_db/naming.rb
45
+ - lib/branch_db/pg_utils.rb
46
+ - lib/branch_db/preparer.rb
47
+ - lib/branch_db/railtie.rb
48
+ - lib/branch_db/tasks/branch_db.rake
49
+ - lib/branch_db/version.rb
50
+ - lib/generators/branch_db/install_generator.rb
51
+ - lib/generators/branch_db/templates/initializer.rb
52
+ - sig/branch_db.rbs
53
+ homepage: https://github.com/milkstrawai/branch_db
54
+ licenses:
55
+ - MIT
56
+ metadata:
57
+ allowed_push_host: https://rubygems.org
58
+ homepage_uri: https://github.com/milkstrawai/branch_db
59
+ source_code_uri: https://github.com/milkstrawai/branch_db
60
+ changelog_uri: https://github.com/milkstrawai/branch_db/blob/main/CHANGELOG.md
61
+ rubygems_mfa_required: 'true'
62
+ rdoc_options: []
63
+ require_paths:
64
+ - lib
65
+ required_ruby_version: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ version: 3.2.0
70
+ required_rubygems_version: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '0'
75
+ requirements: []
76
+ rubygems_version: 3.6.9
77
+ specification_version: 4
78
+ summary: Automatic per-branch PostgreSQL databases for Rails development
79
+ test_files: []