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.
@@ -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