rails-worktree 0.1.6 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8e7212fc3b6e29ec0889e74aa816833e5709c61ffe580138d793eb7e32774b9e
4
- data.tar.gz: 40ffa81276288089b12c97655fda37718fc34ca3414ff475acb961afc073fd9c
3
+ metadata.gz: cd8698c3bbb798a625214a84c2f0e6cc585b4759cd9ef6ae9d3806eb6e6c572a
4
+ data.tar.gz: 98611afb823f7b713a6c12412232d1cc6e85ef023eaad9b0518bb211aa41e628
5
5
  SHA512:
6
- metadata.gz: b0c3ecb5d1e2ba749a205172ba8eb5cfe1fc85fcdf96daa7e55194b92f647fb21497c59e980cab8505854a9c4d034211513b5232a2319e53d4ba12b3f7f44ac2
7
- data.tar.gz: 9d215b260bd60c01c3494d840f022b95ead4f86010632118f8290b738ab021a1c40f9588e550e60aa9a6aa05c2c991e57f7c3e16b99355f2541a0268c70ae49a
6
+ metadata.gz: 71ce31463125cb9756e54f0b66f0b153f39cc8658a9af88c6d8a2a726c22521b5ac5649d5b0a204bfc9775f4c925bfe77648120c734a13953b77eff3781345d2
7
+ data.tar.gz: 96f6e366bdd38b682df8735b368ac26c2b56398bb750d278b0d1556af99cdb7cd97ff811628ce6ed53aaf8d9f061717488d8a84757f354b2d4d03f67802aaed0
data/README.md CHANGED
@@ -4,7 +4,9 @@ Git worktree management for Rails projects with isolated databases and configura
4
4
 
5
5
  ## Features
6
6
 
7
- - Create git worktrees with automatic branch creation
7
+ - Create git worktrees from an explicit base with automatic branch creation
8
+ - Run commands with worktree-local Bundler and binstubs
9
+ - Verify the recorded base before execution and push
8
10
  - Isolated databases per worktree (separate development and test databases)
9
11
  - Automatic configuration file copying
10
12
  - Copy node_modules from main worktree
@@ -21,6 +23,8 @@ end
21
23
  ```
22
24
 
23
25
  Run `bundle install`. A binstub will be automatically created at `bin/worktree`.
26
+ When Rails loads in development, installation also adds `/.worktrees/` to the project's
27
+ `.gitignore`. The manual installer does the same, including when the binstub already exists.
24
28
 
25
29
  Manual binstub installation (if needed):
26
30
  ```bash
@@ -32,16 +36,75 @@ bundle exec rake worktree:install
32
36
  ### Create a worktree
33
37
 
34
38
  ```bash
35
- bin/worktree feature-branch # From current branch
36
- bin/worktree feature-branch main # From specific branch
39
+ git fetch origin
40
+ bin/worktree feature-branch origin/main # Explicit base required
37
41
  ```
38
42
 
43
+ The base is required: omitting it fails before creating a branch or directory. The exact local
44
+ commit resolved from that reference is recorded; creation does not fetch or change your base branch.
45
+ Worktrees are created under `.worktrees/` in the repository root, including when invoked from a subdirectory.
46
+
39
47
  This creates a new worktree with:
40
48
  - Separate databases: `myapp_feature-branch_development` and `myapp_feature-branch_test`
41
49
  - Copied configuration files (`.env`, `database.yml`, `Procfile.dev`, credentials)
42
50
  - Copied `node_modules`
43
51
  - Migrated and seeded databases
44
52
 
53
+ ### Run commands in the worktree
54
+
55
+ ```bash
56
+ cd .worktrees/feature-branch
57
+ bin/worktree exec -- bundle exec rspec
58
+ bin/worktree exec -- bin/rails db:migrate
59
+ bin/worktree verify
60
+ ```
61
+
62
+ `exec` runs from the Git worktree root, including when called from a subdirectory. It checks the
63
+ recorded branch and base ancestry, verifies Bundler's effective root in a fresh Ruby process, and
64
+ pins `BUNDLE_GEMFILE` to this worktree. It clears inherited Bundler state and Git directory overrides,
65
+ removes other repositories' `bin` directories from PATH, and puts this checkout's `bin` first.
66
+ An explicit `bundle` command uses Bundler's gem executable rather than an inherited project binstub.
67
+ Initialization commands use the same isolated environment.
68
+
69
+ Direct commands, `bundle exec` commands, and Ruby script invocations reject binstubs that resolve
70
+ into another repository, including symlinks. Arguments are passed directly without shell expansion;
71
+ command exit status is preserved. Use an explicit `sh -c` when shell syntax is intended. This wrapper
72
+ is an execution-context guard, not a sandbox: it does not inspect shell scripts or prevent application
73
+ code from spawning commands elsewhere. Commands run outside the wrapper are not intercepted.
74
+
75
+ Generated `bin/worktree` launchers are shell scripts that set `BUNDLE_GEMFILE` before starting Ruby,
76
+ so an inherited `RUBYOPT=-rbundler/setup` cannot activate the previous checkout's bundle first.
77
+ Both automatic installation and the Rake installer use the same generator. To replace an existing
78
+ launcher in a project after updating the gem:
79
+
80
+ ```bash
81
+ bundle exec rake worktree:install FORCE=1
82
+ ```
83
+
84
+ Creation replaces the new worktree's launcher with the current generator. Symlinked launchers
85
+ are refused rather than followed. Existing worktrees without recorded metadata cannot use `exec`
86
+ or `verify`; create a managed worktree from an explicit base to use these checks.
87
+
88
+ ### Verify before pushing
89
+
90
+ Creation stores the base reference, its resolved commit, and the feature branch in the private
91
+ `rails-worktree.json` file under `git rev-parse --git-dir`. It does not add this metadata to tracked
92
+ project files. The recorded commit remains fixed even if the base reference advances.
93
+
94
+ Creation also installs a pre-push wrapper at Git's configured hook location, including a custom
95
+ `core.hooksPath`. An existing hook is preserved as `pre-push.before-rails-worktree` and runs after
96
+ verification with the same arguments and input. Its exit status is preserved. Repeated installation
97
+ is idempotent; an unexpected existing backup causes an actionable error instead of overwriting it.
98
+
99
+ In managed worktrees the hook checks the branch, base ancestry, and bundle root, then prints the
100
+ change summary against the recorded base. Non-deletion updates must push the current HEAD; push
101
+ other branches from their own checkout. In checkouts without metadata, the wrapper only runs the
102
+ previous hook. Git hooks are shared by worktrees unless configured otherwise.
103
+
104
+ A branch mismatch tells you to switch back; a base mismatch tells you to rebase onto the recorded
105
+ base. These checks cannot decide whether you chose the right base or whether a change belongs in
106
+ the feature. Review the printed diff before publishing. Git's `--no-verify` still bypasses hooks.
107
+
45
108
  ### Close a worktree
46
109
 
47
110
  ```bash
@@ -1,12 +1,17 @@
1
+ module RailsWorktree
2
+ class Error < StandardError; end
3
+ end
4
+
1
5
  require_relative "worktree/version"
6
+ require_relative "worktree/context"
7
+ require_relative "worktree/launcher"
8
+ require_relative "worktree/installer"
9
+ require_relative "worktree/push_hook"
2
10
  require_relative "worktree/cli"
3
11
  require_relative "worktree/commands/create"
4
12
  require_relative "worktree/commands/init"
5
13
  require_relative "worktree/commands/close"
14
+ require_relative "worktree/commands/exec"
6
15
 
7
16
  # Load Railtie only if Rails is available
8
17
  require_relative "worktree/railtie" if defined?(Rails::Railtie)
9
-
10
- module RailsWorktree
11
- class Error < StandardError; end
12
- end
data/lib/worktree/cli.rb CHANGED
@@ -5,7 +5,7 @@ module RailsWorktree
5
5
  end
6
6
 
7
7
  def initialize(args)
8
- @args = args
8
+ @args = args.dup
9
9
  end
10
10
 
11
11
  def run
@@ -14,6 +14,13 @@ module RailsWorktree
14
14
  exit 1
15
15
  end
16
16
 
17
+ case @args.first
18
+ when "exec"
19
+ return Commands::Exec.new(@args.drop(1)).run
20
+ when "verify"
21
+ return Context.new.verify(push: @args.include?("--push"))
22
+ end
23
+
17
24
  # Extract flags
18
25
  @skip_seeds = @args.delete("--skip-seeds")
19
26
 
@@ -30,20 +37,27 @@ module RailsWorktree
30
37
  # Default: create worktree
31
38
  Commands::Create.new(@args, skip_seeds: @skip_seeds).run
32
39
  end
40
+ rescue Error => error
41
+ warn "Error: #{error.message}"
42
+ exit 1
33
43
  end
34
44
 
35
45
  private
36
46
 
37
47
  def print_usage
38
48
  puts <<~USAGE
39
- Usage: worktree <name> [base-branch] [options]
49
+ Usage: worktree <name> <base-branch> [options]
40
50
  worktree --close [worktree-name]
51
+ worktree exec -- <command> [arguments]
52
+ worktree verify [--push]
41
53
  worktree --init <worktree-name> [options]
42
54
 
43
55
  Creates a new git worktree and initializes it with configuration
44
56
 
45
57
  Commands:
46
58
  <name> Create a new worktree with the given name
59
+ exec -- COMMAND Run a command with verified worktree context
60
+ verify Check the recorded base and bundle root
47
61
  --close [name] Close and remove a worktree
48
62
  --init <name> Initialize a worktree (usually called automatically)
49
63
  --help, -h Show this help message
@@ -53,11 +67,11 @@ module RailsWorktree
53
67
 
54
68
  Arguments:
55
69
  <name> Name of the worktree (required)
56
- [base-branch] Branch to create worktree from (default: current branch)
70
+ <base-branch> Explicit branch or commit to create worktree from (required)
57
71
 
58
72
  Examples:
59
- worktree feature-branch # Create new worktree
60
- worktree feature-branch --skip-seeds # Create without seeding database
73
+ worktree feature-branch origin/main # Create from an explicit base
74
+ worktree feature-branch origin/main --skip-seeds # Create without seeding database
61
75
  worktree --close feature-branch # Close worktree from main repo
62
76
  worktree --close # Close worktree from within it
63
77
  USAGE
@@ -22,11 +22,11 @@ module RailsWorktree
22
22
  puts "Main worktree: #{@main_worktree}"
23
23
  puts ""
24
24
 
25
- # Move to main worktree early so we're not inside the directory we're about to delete
26
- Dir.chdir(@main_worktree) unless @in_main_repo
27
-
28
25
  drop_databases
29
26
  remove_node_modules
27
+
28
+ # Move to main worktree so we're not inside the directory we're about to delete
29
+ Dir.chdir(@main_worktree) unless @in_main_repo
30
30
  remove_worktree
31
31
  prune_worktrees
32
32
  delete_branch
@@ -79,7 +79,7 @@ module RailsWorktree
79
79
  def detect_paths
80
80
  if @current_dir == @main_worktree
81
81
  @in_main_repo = true
82
- @worktree_path = File.join(File.dirname(@main_worktree), @worktree_name)
82
+ @worktree_path = File.join(@main_worktree, ".worktrees", @worktree_name)
83
83
  @worktree_dir = @worktree_path
84
84
  else
85
85
  @in_main_repo = false
@@ -91,16 +91,13 @@ module RailsWorktree
91
91
  def drop_databases
92
92
  puts "Dropping databases..."
93
93
 
94
- if system("dropdb", "--if-exists", @dev_database_name)
95
- puts "Dropped database '#{@dev_database_name}'"
96
- else
97
- puts "Warning: Could not drop development database #{@dev_database_name}"
98
- end
99
-
100
- if system("dropdb", "--if-exists", @test_database_name)
101
- puts "Dropped database '#{@test_database_name}'"
102
- else
103
- puts "Warning: Could not drop test database #{@test_database_name}"
94
+ Dir.chdir(@worktree_path) do
95
+ if File.executable?("bin/rails")
96
+ system({ "DISABLE_DATABASE_ENVIRONMENT_CHECK" => "1" }, "bin/rails", "db:drop") ||
97
+ puts("Warning: Could not drop databases")
98
+ else
99
+ puts "Warning: bin/rails not found in worktree, skipping database drop"
100
+ end
104
101
  end
105
102
  end
106
103
 
@@ -1,3 +1,5 @@
1
+ require "fileutils"
2
+
1
3
  module RailsWorktree
2
4
  module Commands
3
5
  class Create
@@ -13,13 +15,31 @@ module RailsWorktree
13
15
  exit 1
14
16
  end
15
17
 
16
- @base_branch ||= current_branch
17
- worktree_path = "../#{@worktree_name}"
18
+ raise Error, "Base branch required: bin/worktree #{@worktree_name} BASE (for example origin/main)." unless @base_branch
19
+ context = Context.new
20
+ Dir.chdir(context.root) { create }
21
+ end
22
+
23
+ private
24
+
25
+ def create
26
+ _output, error, status = Open3.capture3(Context::GIT_ENVIRONMENT, "git", "check-ref-format", "--branch", @worktree_name)
27
+ raise Error, "Invalid worktree branch: #{error.strip}" unless status.success?
28
+ output, error, status = Open3.capture3(Context::GIT_ENVIRONMENT, "git", "rev-parse", "--verify", "--end-of-options", "#{@base_branch}^{commit}")
29
+ raise Error, "Invalid base #{@base_branch}: #{error.strip}" unless status.success?
30
+ base_commit = output.strip
31
+ worktree_dir = ".worktrees"
32
+ worktree_path = "#{worktree_dir}/#{@worktree_name}"
18
33
  absolute_path = File.expand_path(worktree_path)
19
34
 
35
+ raise Error, "Worktree name must stay inside .worktrees" unless absolute_path.start_with?(File.expand_path(worktree_dir) + File::SEPARATOR)
36
+
37
+ Installer.ignore_worktrees(Dir.pwd)
38
+ FileUtils.mkdir_p(worktree_dir)
39
+
20
40
  puts "Creating worktree '#{@worktree_name}' from branch '#{@base_branch}' at #{worktree_path}..."
21
41
 
22
- unless system("git worktree add -b #{@worktree_name} #{worktree_path} #{@base_branch}")
42
+ unless system(Context::GIT_ENVIRONMENT, "git", "worktree", "add", "-b", @worktree_name, worktree_path, base_commit)
23
43
  puts "Failed to create worktree"
24
44
  exit 1
25
45
  end
@@ -30,6 +50,11 @@ module RailsWorktree
30
50
  puts "Initializing worktree..."
31
51
 
32
52
  Dir.chdir(worktree_path) do
53
+ created = Context.new
54
+ created.record(@base_branch, base_commit, @worktree_name)
55
+ created.verify
56
+ Installer.install(created.root, replace: true)
57
+ PushHook.install(created.root)
33
58
  Init.new([@worktree_name], skip_seeds: @skip_seeds).run
34
59
  end
35
60
 
@@ -39,12 +64,6 @@ module RailsWorktree
39
64
  puts ""
40
65
  puts "To start the development server: bin/dev"
41
66
  end
42
-
43
- private
44
-
45
- def current_branch
46
- `git branch --show-current`.strip
47
- end
48
67
  end
49
68
  end
50
69
  end
@@ -0,0 +1,19 @@
1
+ module RailsWorktree
2
+ module Commands
3
+ class Exec
4
+ def initialize(args)
5
+ @args = args.dup
6
+ @args.shift if @args.first == "--"
7
+ end
8
+
9
+ def run
10
+ context = Context.new
11
+ context.verify
12
+ command = context.command(@args)
13
+ system(context.environment, [command.first, command.first], *command.drop(1), chdir: context.root, unsetenv_others: true)
14
+ status = $?
15
+ exit(status.exitstatus || 128 + status.termsig)
16
+ end
17
+ end
18
+ end
19
+ end
@@ -25,6 +25,9 @@ module RailsWorktree
25
25
  puts "Development database: #{@dev_database_name}"
26
26
  puts "Test database: #{@test_database_name}"
27
27
 
28
+ @context = Context.new
29
+ @context_environment = @context.environment
30
+
28
31
  copy_config_files
29
32
  set_database_names
30
33
  update_database_yml
@@ -65,9 +68,11 @@ module RailsWorktree
65
68
  ".env",
66
69
  "config/database.yml",
67
70
  "Procfile.dev",
68
- "config/credentials/development.key"
71
+ "config/master.key"
69
72
  ]
70
73
 
74
+ files_to_copy += Dir.glob("config/credentials/*", base: @main_worktree)
75
+
71
76
  files_to_copy.each do |file|
72
77
  source = File.join(@main_worktree, file)
73
78
  if File.exist?(source)
@@ -112,13 +117,17 @@ module RailsWorktree
112
117
 
113
118
  content = File.read(database_yml)
114
119
 
115
- # Replace original database names with worktree-specific ones wherever they appear
116
- # This handles hardcoded names, ERB defaults, env var defaults, etc.
117
- original_dev = "#{@db_prefix}_development"
118
- original_test = "#{@db_prefix}_test"
119
-
120
- content.gsub!(original_dev, @dev_database_name)
121
- content.gsub!(original_test, @test_database_name)
120
+ # Use a single-pass replacement to avoid one substitution creating
121
+ # a substring that matches the other pattern (e.g., replacing
122
+ # "app_development" first produces "app_test-branch_development",
123
+ # which contains "app_test" and would be wrongly matched by a
124
+ # second gsub for the test pattern).
125
+ replacements = {
126
+ "#{@db_prefix}_development" => @dev_database_name,
127
+ "#{@db_prefix}_test" => @test_database_name
128
+ }
129
+ pattern = Regexp.union(replacements.keys)
130
+ content.gsub!(pattern) { |match| replacements[match] }
122
131
 
123
132
  File.write(database_yml, content)
124
133
  end
@@ -135,28 +144,34 @@ module RailsWorktree
135
144
  end
136
145
 
137
146
  def database_env
138
- {
147
+ (@context_environment || {}).merge(
148
+ "BUNDLE_GEMFILE" => File.expand_path("Gemfile"),
139
149
  "DATABASE_NAME_DEVELOPMENT" => @dev_database_name,
140
150
  "DATABASE_NAME_TEST" => @test_database_name
141
- }
151
+ )
152
+ end
153
+
154
+ def run_command(environment, *arguments)
155
+ command = @context ? @context.command(arguments) : arguments
156
+ system(environment, *command, unsetenv_others: !!@context_environment)
142
157
  end
143
158
 
144
159
  def setup_database
145
160
  if File.executable?("bin/setup")
146
161
  puts "Running bin/setup..."
147
- system(database_env, "bin/setup") || puts("Warning: bin/setup failed")
162
+ run_command(database_env, "bin/setup") || puts("Warning: bin/setup failed")
148
163
  else
149
164
  puts "Creating databases..."
150
- system(database_env.merge("RAILS_ENV" => "development"), "bin/rails", "db:create") || puts("Warning: Could not create development database")
151
- system(database_env.merge("RAILS_ENV" => "test"), "bin/rails", "db:create") || puts("Warning: Could not create test database")
165
+ run_command(database_env.merge("RAILS_ENV" => "development"), "bin/rails", "db:create") || puts("Warning: Could not create development database")
166
+ run_command(database_env.merge("RAILS_ENV" => "test"), "bin/rails", "db:create") || puts("Warning: Could not create test database")
152
167
 
153
168
  puts "Running migrations..."
154
- system(database_env.merge("RAILS_ENV" => "development"), "bin/rails", "db:migrate") || puts("Warning: Could not run migrations")
155
- system(database_env.merge("RAILS_ENV" => "test"), "bin/rails", "db:migrate") || puts("Warning: Could not run test migrations")
169
+ run_command(database_env.merge("RAILS_ENV" => "development"), "bin/rails", "db:migrate") || puts("Warning: Could not run migrations")
170
+ run_command(database_env.merge("RAILS_ENV" => "test"), "bin/rails", "db:migrate") || puts("Warning: Could not run test migrations")
156
171
 
157
172
  unless @skip_seeds
158
173
  puts "Seeding development database..."
159
- system(database_env.merge("RAILS_ENV" => "development"), "bin/rails", "db:seed") || puts("Warning: Could not seed database")
174
+ run_command(database_env.merge("RAILS_ENV" => "development"), "bin/rails", "db:seed") || puts("Warning: Could not seed database")
160
175
  else
161
176
  puts "Skipping database seeding..."
162
177
  end
@@ -0,0 +1,132 @@
1
+ require "json"
2
+ require "open3"
3
+ require "rbconfig"
4
+ require "bundler"
5
+
6
+ module RailsWorktree
7
+ class Context
8
+ GIT_ENVIRONMENT = {"GIT_DIR" => nil, "GIT_WORK_TREE" => nil, "GIT_INDEX_FILE" => nil, "GIT_COMMON_DIR" => nil}.freeze
9
+
10
+ attr_reader :root
11
+
12
+ def initialize(directory = Dir.pwd)
13
+ @root = File.realpath(git(directory, "rev-parse", "--show-toplevel").strip)
14
+ end
15
+
16
+ def record(base, commit, branch)
17
+ File.write(metadata_path, JSON.pretty_generate({"base" => base, "commit" => commit, "branch" => branch}) + "\n")
18
+ end
19
+
20
+ def verify(push: false)
21
+ data = JSON.parse(File.read(metadata_path))
22
+ raise Error, "Invalid worktree context in #{metadata_path}; expected an object with base, commit and branch." unless data.is_a?(Hash)
23
+ verify_branch(data)
24
+ verify_base(data)
25
+ verify_bundle
26
+ verify_push(data) if push
27
+ data
28
+ rescue Errno::ENOENT, JSON::ParserError, KeyError => error
29
+ raise Error, "Missing or invalid worktree context. Recreate with an explicit base using worktree NAME BASE. #{error.message}"
30
+ end
31
+
32
+ def environment
33
+ env = Bundler.respond_to?(:unbundled_env) ? Bundler.unbundled_env : Bundler.clean_env
34
+ env["PATH"] = executable_path(env.fetch("PATH", ""))
35
+ env["BUNDLE_GEMFILE"] = File.join(root, "Gemfile")
36
+ env.delete("BUNDLE_BIN_PATH")
37
+ GIT_ENVIRONMENT.each_key { |key| env.delete(key) }
38
+ env
39
+ end
40
+
41
+ def command(arguments)
42
+ raise Error, "Command required: bin/worktree exec -- bundle exec rspec" if arguments.empty?
43
+ arguments = arguments.dup
44
+ invoked = arguments[0, 2] == ["bundle", "exec"] ? arguments.drop(2) : arguments
45
+ candidates = invoked.first == "bundle" ? [] : [invoked.first]
46
+ candidates << invoked[1] if invoked.first && File.basename(invoked.first) == "ruby"
47
+ candidates.compact.each { |path| verify_binstub(path) }
48
+ if arguments.first == "bundle"
49
+ arguments.shift
50
+ [RbConfig.ruby, Gem.bin_path("bundler", "bundle"), *arguments]
51
+ else
52
+ arguments
53
+ end
54
+ end
55
+
56
+ def metadata_path
57
+ File.expand_path(git(root, "rev-parse", "--git-path", "rails-worktree.json").strip, root)
58
+ end
59
+
60
+ private
61
+
62
+ def executable_path(path)
63
+ worktree_bins = git(root, "worktree", "list", "--porcelain").lines.grep(/^worktree /).map do |line|
64
+ File.join(line.delete_prefix("worktree ").strip, "bin")
65
+ end
66
+ paths = path.split(File::PATH_SEPARATOR).reject do |directory|
67
+ worktree_bins.include?(directory) || foreign_bin_directory?(directory)
68
+ end
69
+ ([File.join(root, "bin")] + paths).uniq.join(File::PATH_SEPARATOR)
70
+ end
71
+
72
+ def verify_branch(data)
73
+ branch = git(root, "branch", "--show-current").strip
74
+ raise Error, "Worktree branch differs from recorded #{data.fetch("branch")}; switch back before continuing." unless branch == data.fetch("branch")
75
+ end
76
+
77
+ def verify_base(data)
78
+ git(root, "merge-base", "--is-ancestor", data.fetch("commit"), "HEAD")
79
+ rescue Error
80
+ raise Error, "Recorded base #{data.fetch("base")} is no longer an ancestor of HEAD. Rebase onto that base before continuing."
81
+ end
82
+
83
+ def verify_bundle
84
+ gemfile = File.join(root, "Gemfile")
85
+ raise Error, "Missing worktree Gemfile: #{gemfile}" unless File.file?(gemfile) && File.realpath(gemfile) == gemfile
86
+
87
+ output, error, status = Open3.capture3(environment, RbConfig.ruby, "-rbundler", "-e", "puts Bundler.root.realpath", chdir: root, unsetenv_others: true)
88
+ unless status.success? && output.strip == root
89
+ raise Error, "Bundle resolves outside this worktree or cannot load. Run through bin/worktree exec. #{error.strip}"
90
+ end
91
+ end
92
+
93
+ def verify_push(data)
94
+ unless $stdin.tty?
95
+ head = git(root, "rev-parse", "HEAD").strip
96
+ $stdin.each_line do |line|
97
+ _local_ref, sha, _remote_ref, _remote_sha = line.split
98
+ next if sha && sha.match?(/\A0+\z/)
99
+ raise Error, "Push includes a commit other than this worktree's HEAD. Push the current branch separately." unless sha == head
100
+ end
101
+ end
102
+ puts "Worktree base: #{data.fetch("base")} (#{data.fetch("commit")})"
103
+ puts git(root, "diff", "--stat", "#{data.fetch("commit")}...HEAD", "--")
104
+ end
105
+
106
+ def foreign_bin_directory?(path)
107
+ return false unless File.basename(path) == "bin" && File.directory?(path)
108
+ output, _error, status = Open3.capture3(GIT_ENVIRONMENT, "git", "-C", path, "rev-parse", "--show-toplevel")
109
+ status.success? && File.realpath(output.strip) != root
110
+ end
111
+
112
+ def verify_binstub(path)
113
+ resolved = if path.include?(File::SEPARATOR)
114
+ File.expand_path(path, root)
115
+ else
116
+ environment.fetch("PATH").split(File::PATH_SEPARATOR).map { |dir| File.join(dir, path) }.find { |file| File.executable?(file) && !File.directory?(file) }
117
+ end
118
+ return unless resolved && File.exist?(resolved)
119
+ resolved = File.realpath(resolved)
120
+ return if resolved.start_with?(root + File::SEPARATOR)
121
+ return unless resolved.include?("/bin/")
122
+ _output, _error, status = Open3.capture3(GIT_ENVIRONMENT, "git", "-C", File.dirname(resolved), "rev-parse", "--show-toplevel")
123
+ raise Error, "Binstub resolves outside this worktree: #{resolved}. Use this checkout's binstub through bin/worktree exec." if status.success?
124
+ end
125
+
126
+ def git(directory, *arguments)
127
+ output, error, status = Open3.capture3(GIT_ENVIRONMENT, "git", "-C", directory, *arguments)
128
+ raise Error, "Git context check failed (#{arguments.join(" ")}): #{error.strip}" unless status.success?
129
+ output
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,19 @@
1
+ module RailsWorktree
2
+ class Installer
3
+ def self.install(root, replace: false)
4
+ Launcher.install(root, replace: replace)
5
+ ignore_worktrees(root)
6
+ end
7
+
8
+ def self.ignore_worktrees(root)
9
+ path = File.join(root, ".gitignore")
10
+ content = File.exist?(path) ? File.read(path) : ""
11
+ return if content.lines.any? { |line| %w[ .worktrees .worktrees/ /.worktrees /.worktrees/ ].include?(line.strip) }
12
+
13
+ File.open(path, "a") do |file|
14
+ file.puts unless content.empty? || content.end_with?("\n")
15
+ file.puts "/.worktrees/"
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,22 @@
1
+ require "fileutils"
2
+
3
+ module RailsWorktree
4
+ class Launcher
5
+ def self.install(root, replace: false)
6
+ path = File.join(root, "bin/worktree")
7
+ return if File.exist?(path) && !replace
8
+ FileUtils.mkdir_p(File.dirname(path))
9
+ raise Error, "Launcher points outside this worktree: #{path}" unless File.realpath(File.dirname(path)).start_with?(File.realpath(root) + File::SEPARATOR)
10
+ raise Error, "Refusing to replace a symlinked launcher: #{path}" if File.symlink?(path)
11
+ File.write(path, <<~'SH')
12
+ #!/bin/sh
13
+ # Set the bundle before Ruby processes an inherited -rbundler/setup.
14
+ worktree_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd -P) || exit 1
15
+ export BUNDLE_GEMFILE="$worktree_root/Gemfile"
16
+ unset BUNDLE_BIN_PATH
17
+ exec ruby -e 'require "bundler/setup"; require "rails-worktree"; RailsWorktree::CLI.run(ARGV)' -- "$@"
18
+ SH
19
+ File.chmod(0755, path)
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,48 @@
1
+ require "fileutils"
2
+ require "open3"
3
+ require "rbconfig"
4
+
5
+ module RailsWorktree
6
+ class PushHook
7
+ MARKER = "# rails-worktree context hook"
8
+
9
+ def self.install(root)
10
+ output, error, status = Open3.capture3(Context::GIT_ENVIRONMENT, "git", "-C", root, "rev-parse", "--git-path", "hooks/pre-push")
11
+ raise Error, "Cannot locate pre-push hook: #{error}" unless status.success?
12
+ path = File.expand_path(output.strip, root)
13
+ return if File.file?(path) && File.read(path).include?(MARKER)
14
+ previous = "#{path}.before-rails-worktree"
15
+ raise Error, "Hook backup already exists: #{previous}; restore it before installing the worktree hook." if File.exist?(previous)
16
+ FileUtils.mkdir_p(File.dirname(path))
17
+ FileUtils.mv(path, previous) if File.exist?(path)
18
+ File.write(path, <<~RUBY)
19
+ #!#{RbConfig.ruby}
20
+ #{MARKER}
21
+ require "open3"
22
+ require "tempfile"
23
+ root, _, status = Open3.capture3("git", "rev-parse", "--show-toplevel")
24
+ exit 1 unless status.success?
25
+ root = root.strip
26
+ metadata, _, status = Open3.capture3("git", "rev-parse", "--git-path", "rails-worktree.json")
27
+ exit 1 unless status.success?
28
+ Tempfile.create("worktree-push") do |input|
29
+ input.write($stdin.read)
30
+ input.flush
31
+ input.rewind
32
+ if File.file?(File.expand_path(metadata.strip, root))
33
+ launcher = File.join(root, "bin/worktree")
34
+ abort "Missing bin/worktree; reinstall the worktree launcher before pushing." unless File.executable?(launcher)
35
+ exit 1 unless system({"BUNDLE_GEMFILE" => File.join(root, "Gemfile")}, launcher, "verify", "--push", in: input)
36
+ end
37
+ previous = __FILE__ + ".before-rails-worktree"
38
+ if File.executable?(previous)
39
+ input.rewind
40
+ system([previous, previous], *ARGV, in: input)
41
+ exit($?.exitstatus || 128 + $?.termsig)
42
+ end
43
+ end
44
+ RUBY
45
+ File.chmod(0755, path)
46
+ end
47
+ end
48
+ end
@@ -11,23 +11,7 @@ module RailsWorktree
11
11
  initializer "worktree.install_binstub" do
12
12
  # Install binstub automatically when Rails loads in development
13
13
  if Rails.env.development?
14
- binstub_path = Rails.root.join("bin/worktree")
15
-
16
- unless File.exist?(binstub_path)
17
- Rails.logger.info "Installing worktree binstub to bin/worktree..."
18
-
19
- File.write(binstub_path, <<~RUBY)
20
- #!/usr/bin/env ruby
21
-
22
- require "bundler/setup"
23
- require "rails-worktree"
24
-
25
- RailsWorktree::CLI.run(ARGV)
26
- RUBY
27
-
28
- FileUtils.chmod("+x", binstub_path)
29
- Rails.logger.info "✓ Worktree binstub installed! Use: bin/worktree <name>"
30
- end
14
+ Installer.install(Rails.root.to_s)
31
15
  end
32
16
  end
33
17
  end
@@ -1,26 +1,10 @@
1
1
  namespace :worktree do
2
- desc "Install worktree binstub to bin/worktree"
2
+ desc "Install bin/worktree and ignore local .worktrees"
3
3
  task :install do
4
- binstub_path = File.join(Dir.pwd, "bin/worktree")
5
-
6
- if File.exist?(binstub_path)
7
- puts "Binstub already exists at bin/worktree"
8
- exit 0
9
- end
10
-
11
- File.write(binstub_path, <<~RUBY)
12
- #!/usr/bin/env ruby
13
-
14
- require "bundler/setup"
15
- require "rails-worktree"
16
-
17
- RailsWorktree::CLI.run(ARGV)
18
- RUBY
19
-
20
- File.chmod(0755, binstub_path)
4
+ RailsWorktree::Installer.install(Dir.pwd, replace: ENV["FORCE"] == "1")
21
5
 
22
- puts "✓ Worktree binstub installed to bin/worktree"
23
- puts "Usage: bin/worktree <name>"
6
+ puts "✓ bin/worktree is installed and .worktrees is ignored"
7
+ puts "Usage: bin/worktree <name> <base-branch>"
24
8
  end
25
9
 
26
10
  desc "Uninstall worktree binstub from bin/worktree"
@@ -1,3 +1,3 @@
1
1
  module RailsWorktree
2
- VERSION = "0.1.6"
2
+ VERSION = "0.2.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails-worktree
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.6
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Martin Ulleberg
@@ -24,7 +24,12 @@ files:
24
24
  - lib/worktree/cli.rb
25
25
  - lib/worktree/commands/close.rb
26
26
  - lib/worktree/commands/create.rb
27
+ - lib/worktree/commands/exec.rb
27
28
  - lib/worktree/commands/init.rb
29
+ - lib/worktree/context.rb
30
+ - lib/worktree/installer.rb
31
+ - lib/worktree/launcher.rb
32
+ - lib/worktree/push_hook.rb
28
33
  - lib/worktree/railtie.rb
29
34
  - lib/worktree/tasks.rb
30
35
  - lib/worktree/version.rb