rails-worktree 0.2.0 → 0.3.1

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.
@@ -1,12 +1,45 @@
1
+ require "fileutils"
2
+ require "open3"
3
+
1
4
  module RailsWorktree
5
+ # Installs the launcher and the repository integration files:
6
+ # .gitignore entry, .worktree.yml, .worktreeinclude (Claude Code), the
7
+ # AGENTS.md / CLAUDE.md block and the herdr plugin under .worktree/herdr.
2
8
  class Installer
3
- def self.install(root, replace: false)
4
- Launcher.install(root, replace: replace)
5
- ignore_worktrees(root)
9
+ AGENTS_START = "<!-- rails-worktree:start -->".freeze
10
+ AGENTS_FINISH = "<!-- rails-worktree:end -->".freeze
11
+ INCLUDE_START = "# rails-worktree:start".freeze
12
+ INCLUDE_FINISH = "# rails-worktree:end".freeze
13
+ HERDR_DIR = ".worktree/herdr".freeze
14
+
15
+ def self.install(root, replace: false, quiet: true)
16
+ new(root, replace: replace, quiet: quiet).install
6
17
  end
7
18
 
8
19
  def self.ignore_worktrees(root)
9
- path = File.join(root, ".gitignore")
20
+ new(root).ignore_worktrees
21
+ end
22
+
23
+ def initialize(root, replace: false, quiet: true)
24
+ @root = File.expand_path(root)
25
+ @replace = replace
26
+ @quiet = quiet
27
+ @changes = []
28
+ end
29
+
30
+ def install
31
+ Launcher.install(@root, replace: @replace)
32
+ ignore_worktrees
33
+ write_config
34
+ write_worktreeinclude
35
+ write_agents_block
36
+ write_herdr_plugin
37
+ report
38
+ @changes
39
+ end
40
+
41
+ def ignore_worktrees
42
+ path = File.join(@root, ".gitignore")
10
43
  content = File.exist?(path) ? File.read(path) : ""
11
44
  return if content.lines.any? { |line| %w[ .worktrees .worktrees/ /.worktrees /.worktrees/ ].include?(line.strip) }
12
45
 
@@ -14,6 +47,120 @@ module RailsWorktree
14
47
  file.puts unless content.empty? || content.end_with?("\n")
15
48
  file.puts "/.worktrees/"
16
49
  end
50
+ @changes << ".gitignore"
51
+ end
52
+
53
+ private
54
+
55
+ def config
56
+ @config ||= Config.load(@root)
57
+ end
58
+
59
+ def write_config
60
+ path = File.join(@root, Config::FILE)
61
+ return if File.exist?(path)
62
+
63
+ File.write(path, Config::TEMPLATE)
64
+ @changes << Config::FILE
65
+ end
66
+
67
+ def write_worktreeinclude
68
+ body = ["# Gitignored files Claude Code copies into every worktree it creates.",
69
+ "# Generated from the copy list in #{Config::FILE}; rerun bin/worktree install to update."] + config.copy_patterns
70
+ @changes << ".worktreeinclude" if ManagedBlock.write(File.join(@root, ".worktreeinclude"), body.join("\n"), start: INCLUDE_START, finish: INCLUDE_FINISH)
71
+ end
72
+
73
+ def write_agents_block
74
+ body = <<~MARKDOWN
75
+ ## Worktrees
76
+
77
+ A fresh checkout (git worktree, herdr, Claude Code or Codex) has no gitignored
78
+ configuration. Run `bin/worktree init` in it before starting the app or the specs.
79
+ It copies the files listed in `#{Config::FILE}` from the main checkout and is safe to repeat.
80
+
81
+ - `bin/worktree init --level minimal`: configuration files only, shares the main development database.
82
+ - `bin/worktree init --level test`: minimal plus an isolated test setup.
83
+ - `bin/worktree init --level full`: minimal plus own development and test databases.
84
+ - `bin/worktree create <name> <base>`: create a worktree under `.worktrees/` and provision it.
85
+ MARKDOWN
86
+ agents_files.each do |path|
87
+ @changes << File.basename(path) if ManagedBlock.write(path, body, start: AGENTS_START, finish: AGENTS_FINISH)
88
+ end
89
+ end
90
+
91
+ # AGENTS.md and CLAUDE.md when they exist; a symlink to the other file counts once.
92
+ # Without either file, AGENTS.md is created.
93
+ def agents_files
94
+ candidates = %w[AGENTS.md CLAUDE.md].map { |name| File.join(@root, name) }.select { |path| File.exist?(path) }
95
+ return [File.join(@root, "AGENTS.md")] if candidates.empty?
96
+
97
+ candidates.uniq { |path| File.realpath(path) }
98
+ end
99
+
100
+ def write_herdr_plugin
101
+ directory = File.join(@root, HERDR_DIR)
102
+ FileUtils.mkdir_p(directory)
103
+ manifest = File.join(directory, "herdr-plugin.toml")
104
+ script = File.join(directory, "provision")
105
+ app = File.basename(main_checkout).gsub(/[^A-Za-z0-9_.-]/, "-")
106
+ manifest_body = <<~TOML
107
+ # herdr plugin generated by rails-worktree. Link it once per machine:
108
+ # herdr plugin link #{HERDR_DIR}
109
+ # It provisions every worktree herdr creates for this repository.
110
+ id = "rails-worktree.#{app}"
111
+ name = "rails-worktree (#{app})"
112
+ version = "#{VERSION}"
113
+ min_herdr_version = "0.8.0"
114
+ description = "Provision new herdr worktrees with bin/worktree init"
115
+ platforms = ["linux", "macos"]
116
+
117
+ [[events]]
118
+ on = "worktree.created"
119
+ command = ["./provision"]
120
+ TOML
121
+ script_body = <<~'SH'
122
+ #!/bin/sh
123
+ # Runs bin/worktree init in the worktree that herdr just created.
124
+ # Skips checkouts of other repositories (no bin/worktree launcher).
125
+ set -eu
126
+ # herdr wraps the payload: {"event": "worktree_created", "data": {"worktree": {"path": ...}}}.
127
+ path=$(ruby -rjson -e 'event = JSON.parse(ENV.fetch("HERDR_PLUGIN_EVENT_JSON", "{}")); event = event["data"] || event; puts(event.dig("worktree", "path") || event["path"] || "")')
128
+ [ -n "$path" ] || { echo "rails-worktree: no worktree path in the event; skipped" >&2; exit 0; }
129
+ [ -x "$path/bin/worktree" ] || exit 0
130
+ here=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd -P)
131
+ main_of() { git -C "$1" worktree list --porcelain | head -n 1 | sed 's/^worktree //'; }
132
+ [ "$(main_of "$path")" = "$(main_of "$here")" ] || exit 0
133
+ cd "$path"
134
+ # herdr inherits the PATH of the shell that started it, which can put another Ruby first.
135
+ # mise exec selects the Ruby that the checkout's version file asks for.
136
+ if command -v mise >/dev/null 2>&1; then
137
+ exec mise exec -- bin/worktree init --level "${RAILS_WORKTREE_HERDR_LEVEL:-minimal}"
138
+ fi
139
+ exec bin/worktree init --level "${RAILS_WORKTREE_HERDR_LEVEL:-minimal}"
140
+ SH
141
+ write_if_changed(manifest, manifest_body)
142
+ write_if_changed(script, script_body, mode: 0755)
143
+ end
144
+
145
+ # The main checkout's directory, so a worktree installs the same plugin id as the main checkout.
146
+ def main_checkout
147
+ output, _error, status = Open3.capture3(Context::GIT_ENVIRONMENT, "git", "-C", @root, "worktree", "list", "--porcelain")
148
+ first = status.success? && output.lines.grep(/^worktree /).first
149
+ first ? first.delete_prefix("worktree ").strip : @root
150
+ end
151
+
152
+ def write_if_changed(path, body, mode: nil)
153
+ return if File.file?(path) && File.read(path) == body && (mode.nil? || File.executable?(path))
154
+
155
+ File.write(path, body)
156
+ File.chmod(mode, path) if mode
157
+ @changes << path.delete_prefix(@root + File::SEPARATOR)
158
+ end
159
+
160
+ def report
161
+ return if @quiet || @changes.empty?
162
+
163
+ @changes.each { |change| puts " wrote #{change}" }
17
164
  end
18
165
  end
19
166
  end
@@ -0,0 +1,32 @@
1
+ module RailsWorktree
2
+ # Writes a marked block into a text file. Repeated writes replace the block in
3
+ # place and leave the rest of the file untouched.
4
+ class ManagedBlock
5
+ def self.write(path, body, start:, finish:)
6
+ new(path, start: start, finish: finish).write(body)
7
+ end
8
+
9
+ def initialize(path, start:, finish:)
10
+ @path = path
11
+ @start = start
12
+ @finish = finish
13
+ end
14
+
15
+ def write(body)
16
+ block = "#{@start}\n#{body.chomp}\n#{@finish}\n"
17
+ content = File.file?(@path) ? File.read(@path) : ""
18
+ updated =
19
+ if content.include?(@start) && content.include?(@finish)
20
+ content.sub(/#{Regexp.escape(@start)}\n.*?#{Regexp.escape(@finish)}\n?/m) { block }
21
+ elsif content.empty?
22
+ block
23
+ else
24
+ content + (content.end_with?("\n") ? "\n" : "\n\n") + block
25
+ end
26
+ return false if updated == content
27
+
28
+ File.write(@path, updated)
29
+ true
30
+ end
31
+ end
32
+ end
@@ -1,6 +1,5 @@
1
1
  require "fileutils"
2
2
  require "open3"
3
- require "rbconfig"
4
3
 
5
4
  module RailsWorktree
6
5
  class PushHook
@@ -10,13 +9,14 @@ module RailsWorktree
10
9
  output, error, status = Open3.capture3(Context::GIT_ENVIRONMENT, "git", "-C", root, "rev-parse", "--git-path", "hooks/pre-push")
11
10
  raise Error, "Cannot locate pre-push hook: #{error}" unless status.success?
12
11
  path = File.expand_path(output.strip, root)
12
+ return warn_tracked(path) if tracked?(File.dirname(path))
13
13
  return if File.file?(path) && File.read(path).include?(MARKER)
14
14
  previous = "#{path}.before-rails-worktree"
15
15
  raise Error, "Hook backup already exists: #{previous}; restore it before installing the worktree hook." if File.exist?(previous)
16
16
  FileUtils.mkdir_p(File.dirname(path))
17
17
  FileUtils.mv(path, previous) if File.exist?(path)
18
18
  File.write(path, <<~RUBY)
19
- #!#{RbConfig.ruby}
19
+ #!/usr/bin/env ruby
20
20
  #{MARKER}
21
21
  require "open3"
22
22
  require "tempfile"
@@ -44,5 +44,18 @@ module RailsWorktree
44
44
  RUBY
45
45
  File.chmod(0755, path)
46
46
  end
47
+
48
+ def self.tracked?(directory)
49
+ return false unless File.directory?(directory)
50
+ inside, _, status = Open3.capture3(Context::GIT_ENVIRONMENT, "git", "-C", directory, "rev-parse", "--is-inside-work-tree")
51
+ return false unless status.success? && inside.strip == "true"
52
+ output, _, status = Open3.capture3(Context::GIT_ENVIRONMENT, "git", "-C", directory, "ls-files", "--", ".")
53
+ status.success? && !output.empty?
54
+ end
55
+
56
+ def self.warn_tracked(path)
57
+ warn "Skipped the pre-push hook: #{path} is in a directory that Git tracks. " \
58
+ "Run `bin/worktree verify --push` from that hook instead and pass it the hook's standard input."
59
+ end
47
60
  end
48
61
  end
@@ -8,11 +8,9 @@ module RailsWorktree
8
8
  load "worktree/tasks.rb"
9
9
  end
10
10
 
11
- initializer "worktree.install_binstub" do
12
- # Install binstub automatically when Rails loads in development
13
- if Rails.env.development?
14
- Installer.install(Rails.root.to_s)
15
- end
11
+ initializer "worktree.install" do
12
+ # Install the launcher and repository integration files when Rails loads in development.
13
+ Installer.install(Rails.root.to_s) if Rails.env.development?
16
14
  end
17
15
  end
18
16
  end
@@ -1,10 +1,11 @@
1
1
  namespace :worktree do
2
- desc "Install bin/worktree and ignore local .worktrees"
2
+ desc "Install bin/worktree, .worktree.yml, .worktreeinclude, the AGENTS.md block and the herdr plugin"
3
3
  task :install do
4
- RailsWorktree::Installer.install(Dir.pwd, replace: ENV["FORCE"] == "1")
4
+ RailsWorktree::Installer.install(Dir.pwd, replace: ENV["FORCE"] == "1", quiet: false)
5
5
 
6
- puts "✓ bin/worktree is installed and .worktrees is ignored"
7
- puts "Usage: bin/worktree <name> <base-branch>"
6
+ puts "✓ rails-worktree is installed"
7
+ puts "Usage: bin/worktree create <name> <base>"
8
+ puts "Link the herdr plugin once with: herdr plugin link #{RailsWorktree::Installer::HERDR_DIR}"
8
9
  end
9
10
 
10
11
  desc "Uninstall worktree binstub from bin/worktree"
@@ -1,3 +1,3 @@
1
1
  module RailsWorktree
2
- VERSION = "0.2.0"
2
+ VERSION = "0.3.1"
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.2.0
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Martin Ulleberg
@@ -22,13 +22,18 @@ files:
22
22
  - exe/worktree
23
23
  - lib/rails-worktree.rb
24
24
  - lib/worktree/cli.rb
25
+ - lib/worktree/clone_database.rb
25
26
  - lib/worktree/commands/close.rb
26
27
  - lib/worktree/commands/create.rb
27
28
  - lib/worktree/commands/exec.rb
28
29
  - lib/worktree/commands/init.rb
30
+ - lib/worktree/config.rb
29
31
  - lib/worktree/context.rb
32
+ - lib/worktree/copy_sqlite.rb
33
+ - lib/worktree/database_probe.rb
30
34
  - lib/worktree/installer.rb
31
35
  - lib/worktree/launcher.rb
36
+ - lib/worktree/managed_block.rb
32
37
  - lib/worktree/push_hook.rb
33
38
  - lib/worktree/railtie.rb
34
39
  - lib/worktree/tasks.rb
@@ -46,7 +51,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
46
51
  requirements:
47
52
  - - ">="
48
53
  - !ruby/object:Gem::Version
49
- version: 2.6.0
54
+ version: 3.0.0
50
55
  required_rubygems_version: !ruby/object:Gem::Requirement
51
56
  requirements:
52
57
  - - ">="