rails-worktrees 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.release-please-manifest.json +1 -0
- data/CHANGELOG.md +16 -0
- data/LICENSE.txt +21 -0
- data/README.md +160 -0
- data/Rakefile +27 -0
- data/exe/wt +9 -0
- data/lefthook.yml +5 -0
- data/lib/generators/rails/worktrees/install_generator.rb +137 -0
- data/lib/generators/rails/worktrees/mise_follow_up.rb +37 -0
- data/lib/generators/rails/worktrees/templates/Procfile.dev.worktree.example.tt +4 -0
- data/lib/generators/rails/worktrees/templates/bin/wt +7 -0
- data/lib/generators/rails/worktrees/templates/rails_worktrees.rb.tt +21 -0
- data/lib/rails/worktrees/cli.rb +30 -0
- data/lib/rails/worktrees/command/environment_support.rb +47 -0
- data/lib/rails/worktrees/command/git_operations.rb +144 -0
- data/lib/rails/worktrees/command/name_picking.rb +156 -0
- data/lib/rails/worktrees/command/output.rb +131 -0
- data/lib/rails/worktrees/command/workspace_paths.rb +60 -0
- data/lib/rails/worktrees/command.rb +151 -0
- data/lib/rails/worktrees/configuration.rb +45 -0
- data/lib/rails/worktrees/database_config_updater.rb +129 -0
- data/lib/rails/worktrees/env_bootstrapper.rb +153 -0
- data/lib/rails/worktrees/names/cities.txt +96 -0
- data/lib/rails/worktrees/railtie.rb +8 -0
- data/lib/rails/worktrees/version.rb +7 -0
- data/lib/rails/worktrees.rb +32 -0
- data/mise.toml +5 -0
- data/release-please-config.json +12 -0
- data/sig/rails/worktrees.rbs +23 -0
- metadata +98 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'open3'
|
|
4
|
+
|
|
5
|
+
module Rails
|
|
6
|
+
module Worktrees
|
|
7
|
+
class Command
|
|
8
|
+
# Shell-level git helpers, branch/worktree queries, and worktree creation.
|
|
9
|
+
module GitOperations
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def require_git_repo
|
|
13
|
+
return if git_success?('rev-parse', '--is-inside-work-tree')
|
|
14
|
+
|
|
15
|
+
raise Error, 'Run wt from inside a Git repository.'
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def resolve_default_branch
|
|
19
|
+
unless git_success?('remote', 'get-url', 'origin')
|
|
20
|
+
raise Error, "This repository does not have an 'origin' remote."
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
ref = git_capture('symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD', allow_failure: true).strip
|
|
24
|
+
if ref.empty?
|
|
25
|
+
raise Error,
|
|
26
|
+
"Could not resolve origin's default branch. Run 'git fetch origin' and " \
|
|
27
|
+
"'git remote set-head origin -a', then try again."
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
ref.delete_prefix('refs/remotes/origin/')
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def branch_exists_locally?(branch_name)
|
|
34
|
+
git_success?('show-ref', '--verify', '--quiet', "refs/heads/#{branch_name}")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def branch_exists_on_origin?(branch_name)
|
|
38
|
+
git_success?('show-ref', '--verify', '--quiet', "refs/remotes/origin/#{branch_name}")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def branch_is_checked_out_elsewhere?(branch_name)
|
|
42
|
+
worktree_branch_checked_out?(branch_name, worktree_list_output)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def registered_worktree_path?(target_dir)
|
|
46
|
+
normalized_target = canonical_path(target_dir)
|
|
47
|
+
|
|
48
|
+
worktree_list_output.each_line.any? do |line|
|
|
49
|
+
next unless line.start_with?('worktree ')
|
|
50
|
+
|
|
51
|
+
canonical_path(line.delete_prefix('worktree ').strip) == normalized_target
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def worktree_branch_checked_out?(branch_name, output)
|
|
56
|
+
target_branch = "branch refs/heads/#{branch_name}"
|
|
57
|
+
output.each_line.slice_after { |line| line.chomp.empty? }.any? do |lines|
|
|
58
|
+
lines.first&.start_with?('worktree ') && lines.any? { |line| line.chomp == target_branch }
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def worktree_list_output = git_capture('worktree', 'list', '--porcelain')
|
|
63
|
+
|
|
64
|
+
def canonical_path(path)
|
|
65
|
+
File.realpath(path)
|
|
66
|
+
rescue Errno::ENOENT then File.expand_path(path)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def create_new_branch_worktree(branch_name, target_dir)
|
|
70
|
+
default_branch = resolve_default_branch
|
|
71
|
+
if dry_run?
|
|
72
|
+
info("Would create '#{branch_name}' from 'origin/#{default_branch}'")
|
|
73
|
+
return
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
info("Creating '#{branch_name}' from 'origin/#{default_branch}'")
|
|
77
|
+
git!('worktree', 'add', '-b', branch_name, target_dir, "origin/#{default_branch}")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def attach_existing_branch_worktree(branch_name, target_dir)
|
|
81
|
+
if branch_is_checked_out_elsewhere?(branch_name)
|
|
82
|
+
return attach_checked_out_branch_worktree(branch_name, target_dir)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
info("#{dry_run? ? 'Would attach' : 'Attaching'} existing local branch '#{branch_name}'")
|
|
86
|
+
return if dry_run?
|
|
87
|
+
|
|
88
|
+
git!('worktree', 'add', target_dir, branch_name)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def track_remote_branch_worktree(branch_name, target_dir)
|
|
92
|
+
if dry_run?
|
|
93
|
+
info("Would create local tracking branch '#{branch_name}' from 'origin/#{branch_name}'")
|
|
94
|
+
return
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
info("Creating local tracking branch '#{branch_name}' from 'origin/#{branch_name}'")
|
|
98
|
+
git!('worktree', 'add', '--track', '-b', branch_name, target_dir, "origin/#{branch_name}")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def remove_registered_worktree(target_dir)
|
|
102
|
+
info("Removing registered worktree at '#{target_dir}'")
|
|
103
|
+
git!('worktree', 'remove', '--force', target_dir)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def git!(*)
|
|
107
|
+
stdout_str, stderr_str, status = Open3.capture3(@env.to_h, 'git', *, chdir: @cwd)
|
|
108
|
+
return stdout_str if status.success?
|
|
109
|
+
|
|
110
|
+
raise Error, combined_output(stdout_str, stderr_str)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def git_capture(*, allow_failure: false)
|
|
114
|
+
stdout_str, stderr_str, status = Open3.capture3(@env.to_h, 'git', *, chdir: @cwd)
|
|
115
|
+
return stdout_str if status.success? || allow_failure
|
|
116
|
+
|
|
117
|
+
raise Error, combined_output(stdout_str, stderr_str)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def git_success?(*)
|
|
121
|
+
_stdout_str, _stderr_str, status = Open3.capture3(@env.to_h, 'git', *, chdir: @cwd)
|
|
122
|
+
status.success?
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def combined_output(stdout_str, stderr_str)
|
|
126
|
+
output = [stdout_str, stderr_str].map(&:strip).reject(&:empty?).join("\n")
|
|
127
|
+
return output unless output.empty?
|
|
128
|
+
|
|
129
|
+
'git command failed'
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def attach_checked_out_branch_worktree(branch_name, target_dir)
|
|
133
|
+
confirm_or_abort!(
|
|
134
|
+
"'#{branch_name}' is already checked out in another worktree. Create another checkout anyway?"
|
|
135
|
+
)
|
|
136
|
+
info("#{dry_run? ? 'Would create' : 'Creating'} another checkout for '#{branch_name}'")
|
|
137
|
+
return if dry_run?
|
|
138
|
+
|
|
139
|
+
git!('worktree', 'add', '--force', target_dir, branch_name)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rails
|
|
4
|
+
module Worktrees
|
|
5
|
+
class Command
|
|
6
|
+
# Auto-picking names from bundled .txt files and tracking retired names.
|
|
7
|
+
# rubocop:disable Metrics/ModuleLength
|
|
8
|
+
module NamePicking
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def resolve_worktree_name(project_name, workspaces)
|
|
12
|
+
worktree_name = @argv.first
|
|
13
|
+
return validate_worktree_name(worktree_name) if worktree_name && !worktree_name.empty?
|
|
14
|
+
|
|
15
|
+
source_file, auto_name = pick_random_source_name(project_name, workspaces)
|
|
16
|
+
info("Selected #{name_source_label(source_file)} name '#{auto_name}'")
|
|
17
|
+
auto_name
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def pick_random_source_name(project_name, workspaces)
|
|
21
|
+
eligible_sources = name_source_files.filter_map do |source_file|
|
|
22
|
+
available_names = list_available_names(source_file, project_name, workspaces)
|
|
23
|
+
next if available_names.empty?
|
|
24
|
+
|
|
25
|
+
[source_file, available_names]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
if eligible_sources.empty?
|
|
29
|
+
raise Error, "No unused names are available in #{@configuration.name_sources_path}/*.txt"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
selected_source, available_names = eligible_sources.sample
|
|
33
|
+
[selected_source, available_names.sample]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def name_source_files
|
|
37
|
+
path = @configuration.name_sources_path
|
|
38
|
+
files = Dir.glob(File.join(path, '*.txt')).select { |file| File.file?(file) }
|
|
39
|
+
raise Error, "No name list files found in #{path}" if files.empty?
|
|
40
|
+
|
|
41
|
+
files
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def name_source_label(source_file) = File.basename(source_file, '.txt')
|
|
45
|
+
|
|
46
|
+
def list_available_names(source_file, project_name, workspaces)
|
|
47
|
+
source_names(source_file).select do |candidate|
|
|
48
|
+
valid_auto_name?(candidate) && available_for_auto_pick?(candidate, project_name, workspaces)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def source_names(source_file)
|
|
53
|
+
File.readlines(source_file, chomp: true).filter_map do |line|
|
|
54
|
+
name = line.delete_suffix("\r").strip
|
|
55
|
+
next if name.empty? || name.start_with?('#')
|
|
56
|
+
|
|
57
|
+
name
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def available_for_auto_pick?(candidate, project_name, workspaces)
|
|
62
|
+
branch_name = branch_name_for(candidate)
|
|
63
|
+
target_dir = target_dir_for(project_name, candidate, workspaces)
|
|
64
|
+
|
|
65
|
+
!name_is_retired?(candidate) &&
|
|
66
|
+
!branch_exists_locally?(branch_name) &&
|
|
67
|
+
!branch_exists_on_origin?(branch_name) &&
|
|
68
|
+
!File.exist?(target_dir)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def name_exists_in_any_source?(candidate)
|
|
72
|
+
name_source_files.any? { |f| source_names(f).include?(candidate) }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def name_is_retired?(candidate)
|
|
76
|
+
state_file = retired_names_file
|
|
77
|
+
return false unless state_file
|
|
78
|
+
|
|
79
|
+
File.foreach(state_file).any? { |line| line.split("\t", 2).first == candidate }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def record_retired_name(worktree_name, project_name)
|
|
83
|
+
return unless bundled_name_needs_retirement?(worktree_name)
|
|
84
|
+
|
|
85
|
+
ensure_state_store
|
|
86
|
+
timestamp = Time.now.utc.strftime('%Y-%m-%dT%H:%M:%SZ')
|
|
87
|
+
File.open(@configuration.used_names_file, 'a') do |file|
|
|
88
|
+
file.puts([worktree_name, project_name, timestamp].join("\t"))
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def ensure_state_store
|
|
93
|
+
state_file = @configuration.used_names_file
|
|
94
|
+
FileUtils.mkdir_p(File.dirname(state_file))
|
|
95
|
+
return if File.exist?(state_file)
|
|
96
|
+
|
|
97
|
+
legacy_path = Array(@configuration.legacy_used_names_files).find { |path| File.file?(path) }
|
|
98
|
+
FileUtils.cp(legacy_path, state_file) if legacy_path
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def bundled_name_needs_retirement?(worktree_name)
|
|
102
|
+
name_exists_in_any_source?(worktree_name) && !name_is_retired?(worktree_name)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def settle_retired_name(worktree_name, project_name, dry_run: false)
|
|
106
|
+
return record_retired_name(worktree_name, project_name) unless dry_run
|
|
107
|
+
|
|
108
|
+
return unless bundled_name_needs_retirement?(worktree_name)
|
|
109
|
+
|
|
110
|
+
info("Would retire bundled name '#{worktree_name}'")
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def reject_retired_name_for_new_branch(worktree_name)
|
|
114
|
+
return unless name_exists_in_any_source?(worktree_name)
|
|
115
|
+
return unless name_is_retired?(worktree_name)
|
|
116
|
+
|
|
117
|
+
raise Error,
|
|
118
|
+
"Bundled name '#{worktree_name}' has already been used and retired. " \
|
|
119
|
+
"Pick another name or run 'wt' with no argument for a fresh one."
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def validate_worktree_name(worktree_name)
|
|
123
|
+
raise Error, 'Worktree name is required.' if worktree_name.nil? || worktree_name.empty?
|
|
124
|
+
raise Error, "Worktree name must not be '.' or '..'." if %w[. ..].include?(worktree_name)
|
|
125
|
+
raise Error, "Worktree name must not contain '/'." if worktree_name.include?('/')
|
|
126
|
+
raise Error, 'Worktree name must not contain spaces.' if worktree_name.match?(/\s/)
|
|
127
|
+
unless git_valid_ref_name?(worktree_name)
|
|
128
|
+
raise Error, "Worktree name '#{worktree_name}' is not a valid Git ref component."
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
worktree_name
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def git_valid_ref_name?(name)
|
|
135
|
+
git_success?('check-ref-format', '--branch', name)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def valid_auto_name?(worktree_name)
|
|
139
|
+
validate_worktree_name(worktree_name)
|
|
140
|
+
true
|
|
141
|
+
rescue Error
|
|
142
|
+
false
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def retired_names_file
|
|
146
|
+
if File.file?(@configuration.used_names_file)
|
|
147
|
+
@configuration.used_names_file
|
|
148
|
+
else
|
|
149
|
+
Array(@configuration.legacy_used_names_files).find { |path| File.file?(path) }
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
# rubocop:enable Metrics/ModuleLength
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rails
|
|
4
|
+
module Worktrees
|
|
5
|
+
class Command
|
|
6
|
+
# User-facing output, prompts, and help text.
|
|
7
|
+
module Output
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def handle_meta_command
|
|
11
|
+
case @argv.first
|
|
12
|
+
when '-h', '--help'
|
|
13
|
+
@stdout.print(usage)
|
|
14
|
+
0
|
|
15
|
+
when '-v', '--version'
|
|
16
|
+
@stdout.puts("wt #{Rails::Worktrees::VERSION}")
|
|
17
|
+
0
|
|
18
|
+
when '--env', '--print-env'
|
|
19
|
+
preview_worktree_environment_command
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def usage_error
|
|
24
|
+
@stderr.print(usage)
|
|
25
|
+
1
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def usage
|
|
29
|
+
<<~USAGE
|
|
30
|
+
wt #{::Rails::Worktrees::VERSION}
|
|
31
|
+
Create Git worktrees for the current repository.
|
|
32
|
+
|
|
33
|
+
Usage: wt [worktree-name]
|
|
34
|
+
wt --dry-run [worktree-name]
|
|
35
|
+
wt --print-env <worktree-name>
|
|
36
|
+
|
|
37
|
+
Options:
|
|
38
|
+
-h, --help Show this help message
|
|
39
|
+
-v, --version Show the script version
|
|
40
|
+
--dry-run [name] Preview the full worktree setup without changing anything
|
|
41
|
+
--env, --print-env <name> Preview DEV_PORT and WORKTREE_DATABASE_SUFFIX
|
|
42
|
+
|
|
43
|
+
Quick start:
|
|
44
|
+
wt Auto-pick a name from a bundled *.txt list
|
|
45
|
+
wt my-feature Use an explicit worktree name
|
|
46
|
+
wt --dry-run my-feature
|
|
47
|
+
wt --print-env my-feature
|
|
48
|
+
|
|
49
|
+
How it works:
|
|
50
|
+
- by default creates worktrees beside the repo in ../<project>.worktrees/<name>
|
|
51
|
+
- when workspace_root or WT_WORKSPACES_ROOT is set, creates worktrees in <root>/<project>/<name>
|
|
52
|
+
- always uses the branch name #{@configuration.branch_prefix}/<name>
|
|
53
|
+
- bases new branches on the repository's origin default branch
|
|
54
|
+
- auto-discovers bundled *.txt files from #{@configuration.name_sources_path}
|
|
55
|
+
- retires bundled names in #{@configuration.used_names_file}
|
|
56
|
+
USAGE
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def confirm_or_abort!(prompt)
|
|
60
|
+
if dry_run?
|
|
61
|
+
info("Would confirm: #{prompt}")
|
|
62
|
+
return
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
raise Error, 'Aborted.' unless confirm?(prompt)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def confirm?(prompt)
|
|
69
|
+
return false unless @stdin.respond_to?(:tty?) && @stdin.tty?
|
|
70
|
+
|
|
71
|
+
@stdout.print("#{prompt} [y/N] ")
|
|
72
|
+
response = @stdin.gets.to_s.strip
|
|
73
|
+
response.match?(/\A(?:y|yes)\z/i)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def info(message)
|
|
77
|
+
@stdout.puts("→ #{message}")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def announce_dry_run = info('Dry run: previewing worktree setup without making changes')
|
|
81
|
+
|
|
82
|
+
def complete_dry_run(context, env_values:)
|
|
83
|
+
success('Dry run complete')
|
|
84
|
+
print_context_summary(context, env_values: env_values)
|
|
85
|
+
info('No changes were made.')
|
|
86
|
+
0
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def complete_reuse_dry_run(context, target:, branch:)
|
|
90
|
+
preview_result = preview_worktree_environment(context)
|
|
91
|
+
info("Would reuse existing worktree at '#{target}' on '#{branch}'")
|
|
92
|
+
complete_dry_run(context, env_values: preview_result&.values)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def warning(message)
|
|
96
|
+
@stderr.puts("⚠️ #{message}")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def success(message)
|
|
100
|
+
@stdout.puts("✅ #{message}")
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def print_env_preview(values)
|
|
104
|
+
values.each { |key, value| @stdout.puts("#{key}=#{value}") }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def print_context_summary(context, target_dir: context[:target_dir], branch_name: context[:branch_name],
|
|
108
|
+
env_values: nil)
|
|
109
|
+
print_worktree_summary(
|
|
110
|
+
target_dir,
|
|
111
|
+
branch_name,
|
|
112
|
+
context[:workspaces_root],
|
|
113
|
+
uses_default_workspace_root: context[:uses_default_workspace_root],
|
|
114
|
+
env_values: env_values
|
|
115
|
+
)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def print_worktree_summary(target_dir, branch_name, workspaces_root, uses_default_workspace_root:, env_values:)
|
|
119
|
+
@stdout.puts("Root: #{workspaces_root}") unless uses_default_workspace_root
|
|
120
|
+
@stdout.puts("Path: #{target_dir}")
|
|
121
|
+
@stdout.puts("Branch: #{branch_name}")
|
|
122
|
+
@stdout.puts("Port: #{env_values['DEV_PORT']}") if env_values && env_values['DEV_PORT']
|
|
123
|
+
suffix = env_values && env_values['WORKTREE_DATABASE_SUFFIX']
|
|
124
|
+
return unless suffix
|
|
125
|
+
|
|
126
|
+
@stdout.puts("Suffix: #{suffix}")
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rails
|
|
4
|
+
module Worktrees
|
|
5
|
+
class Command
|
|
6
|
+
# Resolves the default and explicit workspace path layouts.
|
|
7
|
+
module WorkspacePaths
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def resolve_workspaces(repo_root, project_name)
|
|
11
|
+
explicit_root = configured_workspaces_root
|
|
12
|
+
return { root: explicit_root, uses_default_root: false } if explicit_root
|
|
13
|
+
|
|
14
|
+
{ root: File.join(File.dirname(repo_root), "#{project_name}.worktrees"), uses_default_root: true }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def configured_workspaces_root
|
|
18
|
+
env_root = @env['WT_WORKSPACES_ROOT']
|
|
19
|
+
return expand_home(env_root) if present_path?(env_root)
|
|
20
|
+
|
|
21
|
+
config_root = @configuration.workspace_root
|
|
22
|
+
return expand_home(config_root) if present_path?(config_root)
|
|
23
|
+
|
|
24
|
+
nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def target_dir_for(project_name, worktree_name, workspaces)
|
|
28
|
+
if workspaces[:uses_default_root]
|
|
29
|
+
File.join(workspaces[:root], worktree_name)
|
|
30
|
+
else
|
|
31
|
+
File.join(workspaces[:root], project_name, worktree_name)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def prepare_target_parent(target_dir)
|
|
36
|
+
parent_dir = File.dirname(target_dir)
|
|
37
|
+
return if File.directory?(parent_dir)
|
|
38
|
+
return info("Would create workspace directory '#{parent_dir}'") if dry_run?
|
|
39
|
+
|
|
40
|
+
FileUtils.mkdir_p(parent_dir)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def present_path?(path)
|
|
44
|
+
!path.nil? && !path.empty?
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def expand_home(path)
|
|
48
|
+
case path
|
|
49
|
+
when '~'
|
|
50
|
+
Dir.home
|
|
51
|
+
when %r{\A~/}
|
|
52
|
+
File.join(Dir.home, path.delete_prefix('~/'))
|
|
53
|
+
else
|
|
54
|
+
path
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require_relative 'command/environment_support'
|
|
5
|
+
require_relative 'command/git_operations'
|
|
6
|
+
require_relative 'command/name_picking'
|
|
7
|
+
require_relative 'command/output'
|
|
8
|
+
require_relative 'command/workspace_paths'
|
|
9
|
+
|
|
10
|
+
module Rails
|
|
11
|
+
module Worktrees
|
|
12
|
+
# Creates or attaches worktrees for the current repository.
|
|
13
|
+
# rubocop:disable Metrics/ClassLength
|
|
14
|
+
class Command
|
|
15
|
+
include GitOperations
|
|
16
|
+
include EnvironmentSupport
|
|
17
|
+
include NamePicking
|
|
18
|
+
include Output
|
|
19
|
+
include WorkspacePaths
|
|
20
|
+
|
|
21
|
+
def initialize(argv:, io:, env:, cwd:, configuration:)
|
|
22
|
+
@argv = argv.dup
|
|
23
|
+
@stdin = io.fetch(:stdin)
|
|
24
|
+
@stdout = io.fetch(:stdout)
|
|
25
|
+
@stderr = io.fetch(:stderr)
|
|
26
|
+
@env = env
|
|
27
|
+
@cwd = cwd
|
|
28
|
+
@configuration = configuration
|
|
29
|
+
@dry_run = @argv.first == '--dry-run'
|
|
30
|
+
@argv.shift if dry_run?
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def run
|
|
34
|
+
return usage_error if dry_run? && @argv.first&.start_with?('-')
|
|
35
|
+
|
|
36
|
+
meta_command_result = handle_meta_command
|
|
37
|
+
return meta_command_result unless meta_command_result.nil?
|
|
38
|
+
return usage_error if @argv.length > 1
|
|
39
|
+
|
|
40
|
+
execute_worktree_command
|
|
41
|
+
rescue Error => e
|
|
42
|
+
@stderr.puts("Error: #{e.message}")
|
|
43
|
+
1
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def dry_run? = @dry_run
|
|
49
|
+
|
|
50
|
+
def execute_worktree_command
|
|
51
|
+
require_git_repo
|
|
52
|
+
announce_dry_run if dry_run?
|
|
53
|
+
context = resolve_worktree_context
|
|
54
|
+
|
|
55
|
+
if File.exist?(context[:target_dir])
|
|
56
|
+
return finish_reuse(context) if worktree_matches_branch?(context)
|
|
57
|
+
|
|
58
|
+
confirm_and_remove_target(context[:target_dir])
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
prepare_target_parent(context[:target_dir])
|
|
62
|
+
attach_or_create_worktree(context)
|
|
63
|
+
finish(context)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def resolve_worktree_context(explicit_worktree_name: nil)
|
|
67
|
+
repo_root = git_capture('rev-parse', '--show-toplevel').strip
|
|
68
|
+
project_name = File.basename(repo_root)
|
|
69
|
+
workspaces = resolve_workspaces(repo_root, project_name)
|
|
70
|
+
worktree_name = resolved_worktree_name(project_name, workspaces, explicit_worktree_name)
|
|
71
|
+
|
|
72
|
+
{ project_name: project_name, workspaces_root: workspaces[:root], worktree_name: worktree_name,
|
|
73
|
+
uses_default_workspace_root: workspaces[:uses_default_root],
|
|
74
|
+
branch_name: branch_name_for(worktree_name),
|
|
75
|
+
target_dir: target_dir_for(project_name, worktree_name, workspaces) }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def resolved_worktree_name(project_name, workspaces, explicit_worktree_name)
|
|
79
|
+
return validate_worktree_name(explicit_worktree_name) if explicit_worktree_name
|
|
80
|
+
|
|
81
|
+
resolve_worktree_name(project_name, workspaces)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def attach_or_create_worktree(context)
|
|
85
|
+
branch, target = context.values_at(:branch_name, :target_dir)
|
|
86
|
+
|
|
87
|
+
if branch_exists_locally?(branch)
|
|
88
|
+
confirm_or_abort!("Branch '#{branch}' already exists locally. Attach a new worktree to it?")
|
|
89
|
+
attach_existing_branch_worktree(branch, target)
|
|
90
|
+
elsif branch_exists_on_origin?(branch)
|
|
91
|
+
confirm_or_abort!("Branch '#{branch}' already exists on origin. Create a local tracking worktree?")
|
|
92
|
+
track_remote_branch_worktree(branch, target)
|
|
93
|
+
else
|
|
94
|
+
create_fresh_branch_worktree(context)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def create_fresh_branch_worktree(context)
|
|
99
|
+
reject_retired_name_for_new_branch(context[:worktree_name])
|
|
100
|
+
create_new_branch_worktree(context[:branch_name], context[:target_dir])
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def finish(context)
|
|
104
|
+
settle_retired_name(context[:worktree_name], context[:project_name], dry_run: dry_run?)
|
|
105
|
+
bootstrap_result = bootstrap_worktree_environment(context)
|
|
106
|
+
return complete_dry_run(context, env_values: bootstrap_result&.values) if dry_run?
|
|
107
|
+
|
|
108
|
+
success('Worktree ready')
|
|
109
|
+
print_context_summary(context, env_values: bootstrap_result&.values)
|
|
110
|
+
0
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def finish_reuse(context)
|
|
114
|
+
target, branch = context.values_at(:target_dir, :branch_name)
|
|
115
|
+
|
|
116
|
+
confirm_or_abort!("Worktree already exists at '#{target}' on '#{branch}'. Reuse it?")
|
|
117
|
+
settle_retired_name(context[:worktree_name], context[:project_name], dry_run: dry_run?)
|
|
118
|
+
return complete_reuse_dry_run(context, target: target, branch: branch) if dry_run?
|
|
119
|
+
|
|
120
|
+
bootstrap_result = bootstrap_worktree_environment(context)
|
|
121
|
+
success('Reusing existing worktree')
|
|
122
|
+
print_context_summary(context, target_dir: target, branch_name: branch, env_values: bootstrap_result&.values)
|
|
123
|
+
0
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def worktree_matches_branch?(context)
|
|
127
|
+
target = context[:target_dir]
|
|
128
|
+
return false unless git_success?('-C', target, 'rev-parse', '--is-inside-work-tree')
|
|
129
|
+
|
|
130
|
+
existing = git_capture('-C', target, 'branch', '--show-current', allow_failure: true).strip
|
|
131
|
+
existing == context[:branch_name]
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def confirm_and_remove_target(target_dir)
|
|
135
|
+
confirm_or_abort!("Target path '#{target_dir}' already exists. Remove it and recreate the worktree?")
|
|
136
|
+
return info("Would remove existing target path '#{target_dir}'") if dry_run?
|
|
137
|
+
|
|
138
|
+
if registered_worktree_path?(target_dir)
|
|
139
|
+
remove_registered_worktree(target_dir)
|
|
140
|
+
else
|
|
141
|
+
FileUtils.rm_rf(target_dir)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def branch_name_for(worktree_name)
|
|
146
|
+
"#{@configuration.branch_prefix}/#{worktree_name}"
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
# rubocop:enable Metrics/ClassLength
|
|
150
|
+
end
|
|
151
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rails
|
|
4
|
+
module Worktrees
|
|
5
|
+
# Stores application-level settings for the wt command.
|
|
6
|
+
class Configuration
|
|
7
|
+
DEFAULT_BOOTSTRAP_ENV = true
|
|
8
|
+
DEFAULT_BRANCH_PREFIX = '🚂'
|
|
9
|
+
DEFAULT_DEV_PORT_RANGE = (3000..3999)
|
|
10
|
+
DEFAULT_USED_NAMES_DIRECTORY = File.join(
|
|
11
|
+
ENV.fetch('XDG_STATE_HOME', File.join(Dir.home, '.local/state')),
|
|
12
|
+
'rails-worktrees'
|
|
13
|
+
)
|
|
14
|
+
DEFAULT_USED_NAMES_FILE = File.join(DEFAULT_USED_NAMES_DIRECTORY, 'used-names.tsv')
|
|
15
|
+
DEFAULT_NAME_SOURCES_PATH = File.expand_path('names', __dir__)
|
|
16
|
+
DEFAULT_WORKTREE_DATABASE_SUFFIX_MAX_LENGTH = 18
|
|
17
|
+
|
|
18
|
+
attr_accessor :bootstrap_env, :branch_prefix, :dev_port_range, :legacy_used_names_files,
|
|
19
|
+
:name_sources_path, :used_names_file, :workspace_root,
|
|
20
|
+
:worktree_database_suffix_max_length
|
|
21
|
+
|
|
22
|
+
def initialize
|
|
23
|
+
@bootstrap_env = DEFAULT_BOOTSTRAP_ENV
|
|
24
|
+
@workspace_root = nil
|
|
25
|
+
@branch_prefix = DEFAULT_BRANCH_PREFIX
|
|
26
|
+
@dev_port_range = DEFAULT_DEV_PORT_RANGE
|
|
27
|
+
@name_sources_path = DEFAULT_NAME_SOURCES_PATH
|
|
28
|
+
@used_names_file = DEFAULT_USED_NAMES_FILE
|
|
29
|
+
@worktree_database_suffix_max_length = DEFAULT_WORKTREE_DATABASE_SUFFIX_MAX_LENGTH
|
|
30
|
+
@legacy_used_names_files = default_legacy_used_names_files
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def default_legacy_used_names_files
|
|
36
|
+
state_home = ENV.fetch('XDG_STATE_HOME', File.join(Dir.home, '.local/state'))
|
|
37
|
+
|
|
38
|
+
[
|
|
39
|
+
File.join(state_home, 'wt', 'used-names.tsv'),
|
|
40
|
+
File.join(state_home, 'wt', 'used-cities.tsv')
|
|
41
|
+
]
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|