sxn 0.2.3 → 0.3.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 +4 -4
- data/CHANGELOG.md +47 -0
- data/Gemfile.lock +1 -1
- data/lib/sxn/CLI.rb +107 -4
- data/lib/sxn/commands/init.rb +136 -0
- data/lib/sxn/commands/sessions.rb +307 -7
- data/lib/sxn/commands/templates.rb +230 -0
- data/lib/sxn/commands/worktrees.rb +17 -1
- data/lib/sxn/commands.rb +1 -0
- data/lib/sxn/config/templates_config.rb +153 -0
- data/lib/sxn/config.rb +1 -0
- data/lib/sxn/core/config_manager.rb +4 -0
- data/lib/sxn/core/project_manager.rb +19 -2
- data/lib/sxn/core/rules_manager.rb +61 -3
- data/lib/sxn/core/session_config.rb +96 -0
- data/lib/sxn/core/session_manager.rb +29 -3
- data/lib/sxn/core/template_manager.rb +187 -0
- data/lib/sxn/core/worktree_manager.rb +38 -11
- data/lib/sxn/core.rb +2 -0
- data/lib/sxn/errors.rb +34 -2
- data/lib/sxn/ui/prompt.rb +4 -0
- data/lib/sxn/ui/table.rb +18 -1
- data/lib/sxn/version.rb +1 -1
- metadata +5 -1
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "pathname"
|
|
5
|
+
|
|
6
|
+
module Sxn
|
|
7
|
+
module Config
|
|
8
|
+
# Handles loading and saving session templates from .sxn/templates.yml
|
|
9
|
+
#
|
|
10
|
+
# Templates define collections of projects (worktree configurations)
|
|
11
|
+
# that can be applied when creating a session.
|
|
12
|
+
class TemplatesConfig
|
|
13
|
+
TEMPLATES_FILE = "templates.yml"
|
|
14
|
+
|
|
15
|
+
attr_reader :sxn_path, :templates_file_path
|
|
16
|
+
|
|
17
|
+
def initialize(sxn_path)
|
|
18
|
+
@sxn_path = Pathname.new(sxn_path)
|
|
19
|
+
@templates_file_path = @sxn_path / TEMPLATES_FILE
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Load templates configuration from file
|
|
23
|
+
# @return [Hash] Templates configuration
|
|
24
|
+
def load
|
|
25
|
+
return default_config unless templates_file_path.exist?
|
|
26
|
+
|
|
27
|
+
content = File.read(templates_file_path)
|
|
28
|
+
config = YAML.safe_load(content, permitted_classes: [], permitted_symbols: [], aliases: false) || {}
|
|
29
|
+
normalize_config(config)
|
|
30
|
+
rescue Psych::SyntaxError => e
|
|
31
|
+
raise ConfigurationError, "Invalid YAML in #{templates_file_path}: #{e.message}"
|
|
32
|
+
rescue StandardError => e
|
|
33
|
+
raise ConfigurationError, "Failed to load templates file #{templates_file_path}: #{e.message}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Save templates configuration to file
|
|
37
|
+
# @param config [Hash] Templates configuration
|
|
38
|
+
def save(config)
|
|
39
|
+
ensure_directory_exists!
|
|
40
|
+
|
|
41
|
+
# Ensure version is set
|
|
42
|
+
config["version"] ||= 1
|
|
43
|
+
config["templates"] ||= {}
|
|
44
|
+
|
|
45
|
+
File.write(templates_file_path, YAML.dump(stringify_keys(config)))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Check if templates file exists
|
|
49
|
+
# @return [Boolean]
|
|
50
|
+
def exists?
|
|
51
|
+
templates_file_path.exist?
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Get a specific template by name
|
|
55
|
+
# @param name [String] Template name
|
|
56
|
+
# @return [Hash, nil] Template configuration or nil if not found
|
|
57
|
+
def get_template(name)
|
|
58
|
+
config = load
|
|
59
|
+
templates = config["templates"] || {}
|
|
60
|
+
template = templates[name]
|
|
61
|
+
|
|
62
|
+
return nil unless template
|
|
63
|
+
|
|
64
|
+
# Normalize project entries to hashes
|
|
65
|
+
normalize_template(name, template)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# List all template names
|
|
69
|
+
# @return [Array<String>] Template names
|
|
70
|
+
def list_template_names
|
|
71
|
+
config = load
|
|
72
|
+
(config["templates"] || {}).keys
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Add or update a template
|
|
76
|
+
# @param name [String] Template name
|
|
77
|
+
# @param template [Hash] Template configuration
|
|
78
|
+
def set_template(name, template)
|
|
79
|
+
config = load
|
|
80
|
+
config["templates"] ||= {}
|
|
81
|
+
config["templates"][name] = template
|
|
82
|
+
save(config)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Remove a template
|
|
86
|
+
# @param name [String] Template name
|
|
87
|
+
# @return [Boolean] True if template was removed
|
|
88
|
+
def remove_template(name)
|
|
89
|
+
config = load
|
|
90
|
+
templates = config["templates"] || {}
|
|
91
|
+
|
|
92
|
+
return false unless templates.key?(name)
|
|
93
|
+
|
|
94
|
+
templates.delete(name)
|
|
95
|
+
save(config)
|
|
96
|
+
true
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
def default_config
|
|
102
|
+
{
|
|
103
|
+
"version" => 1,
|
|
104
|
+
"templates" => {}
|
|
105
|
+
}
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def normalize_config(config)
|
|
109
|
+
config["version"] ||= 1
|
|
110
|
+
config["templates"] ||= {}
|
|
111
|
+
config
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def normalize_template(name, template)
|
|
115
|
+
{
|
|
116
|
+
"name" => name,
|
|
117
|
+
"description" => template["description"],
|
|
118
|
+
"projects" => normalize_projects(template["projects"] || [])
|
|
119
|
+
}
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def normalize_projects(projects)
|
|
123
|
+
projects.map do |project|
|
|
124
|
+
if project.is_a?(String)
|
|
125
|
+
{ "name" => project }
|
|
126
|
+
elsif project.is_a?(Hash)
|
|
127
|
+
# Ensure name key exists
|
|
128
|
+
project["name"] ||= project[:name]
|
|
129
|
+
stringify_keys(project)
|
|
130
|
+
else
|
|
131
|
+
{ "name" => project.to_s }
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def stringify_keys(hash)
|
|
137
|
+
return hash unless hash.is_a?(Hash)
|
|
138
|
+
|
|
139
|
+
hash.transform_keys(&:to_s).transform_values do |value|
|
|
140
|
+
case value
|
|
141
|
+
when Hash then stringify_keys(value)
|
|
142
|
+
when Array then value.map { |v| v.is_a?(Hash) ? stringify_keys(v) : v }
|
|
143
|
+
else value
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def ensure_directory_exists!
|
|
149
|
+
FileUtils.mkdir_p(sxn_path) unless sxn_path.exist?
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
data/lib/sxn/config.rb
CHANGED
|
@@ -62,6 +62,10 @@ module Sxn
|
|
|
62
62
|
@sessions_folder || (load_config && @sessions_folder)
|
|
63
63
|
end
|
|
64
64
|
|
|
65
|
+
def sxn_folder_path
|
|
66
|
+
File.dirname(@config_path)
|
|
67
|
+
end
|
|
68
|
+
|
|
65
69
|
def add_project(name, path, type: nil, default_branch: nil)
|
|
66
70
|
config = load_config_file
|
|
67
71
|
config["projects"] ||= {}
|
|
@@ -193,9 +193,26 @@ module Sxn
|
|
|
193
193
|
|
|
194
194
|
# Get project-specific rules from config
|
|
195
195
|
config = @config_manager.get_config
|
|
196
|
-
project_config = config.projects[name]
|
|
197
196
|
|
|
198
|
-
|
|
197
|
+
# Handle both OpenStruct and Hash for projects config
|
|
198
|
+
projects = config.projects
|
|
199
|
+
project_config = if projects.is_a?(OpenStruct)
|
|
200
|
+
projects.to_h[name.to_sym] || projects.to_h[name]
|
|
201
|
+
elsif projects.is_a?(Hash)
|
|
202
|
+
projects[name]
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Extract rules, handling both OpenStruct and Hash
|
|
206
|
+
rules = if project_config.is_a?(OpenStruct)
|
|
207
|
+
project_config.to_h[:rules] || project_config.to_h["rules"] || {}
|
|
208
|
+
elsif project_config.is_a?(Hash)
|
|
209
|
+
project_config["rules"] || project_config[:rules] || {}
|
|
210
|
+
else
|
|
211
|
+
{}
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Convert OpenStruct rules to hash if needed
|
|
215
|
+
rules = rules.to_h if rules.is_a?(OpenStruct)
|
|
199
216
|
|
|
200
217
|
# Add default rules based on project type
|
|
201
218
|
default_rules = get_default_rules_for_type(project[:type])
|
|
@@ -87,11 +87,69 @@ module Sxn
|
|
|
87
87
|
"No worktree found for project '#{project_name}' in session '#{session_name}'"
|
|
88
88
|
end
|
|
89
89
|
|
|
90
|
-
# Get project rules
|
|
90
|
+
# Get project rules (format: { "copy_files" => [...], "setup_commands" => [...] })
|
|
91
91
|
rules = @project_manager.get_project_rules(project_name)
|
|
92
92
|
|
|
93
|
-
#
|
|
94
|
-
|
|
93
|
+
# Transform rules to RulesEngine format and apply
|
|
94
|
+
apply_rules_to_worktree(project, worktree, rules)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def apply_rules_to_worktree(project, worktree, rules)
|
|
98
|
+
project_path = project[:path]
|
|
99
|
+
worktree_path = worktree[:path]
|
|
100
|
+
|
|
101
|
+
# Ensure paths exist
|
|
102
|
+
raise Sxn::InvalidProjectPathError, "Project path does not exist: #{project_path}" unless File.directory?(project_path)
|
|
103
|
+
raise Sxn::WorktreeNotFoundError, "Worktree path does not exist: #{worktree_path}" unless File.directory?(worktree_path)
|
|
104
|
+
|
|
105
|
+
applied_count = 0
|
|
106
|
+
errors = []
|
|
107
|
+
|
|
108
|
+
# Apply copy_files rules
|
|
109
|
+
rules["copy_files"]&.each do |rule_config|
|
|
110
|
+
apply_copy_file_rule(project_path, worktree_path, rule_config)
|
|
111
|
+
applied_count += 1
|
|
112
|
+
rescue StandardError => e
|
|
113
|
+
errors << "copy_files: #{e.message}"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Apply setup_commands rules (skip for now as they can be slow)
|
|
117
|
+
# Users can run these manually if needed
|
|
118
|
+
|
|
119
|
+
{
|
|
120
|
+
success: errors.empty?,
|
|
121
|
+
applied_count: applied_count,
|
|
122
|
+
errors: errors
|
|
123
|
+
}
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def apply_copy_file_rule(project_path, worktree_path, rule_config)
|
|
127
|
+
source_pattern = rule_config["source"]
|
|
128
|
+
strategy = rule_config["strategy"] || "copy"
|
|
129
|
+
|
|
130
|
+
# Handle glob patterns
|
|
131
|
+
source_files = if source_pattern.include?("*")
|
|
132
|
+
Dir.glob(File.join(project_path, source_pattern))
|
|
133
|
+
else
|
|
134
|
+
single_file = File.join(project_path, source_pattern)
|
|
135
|
+
File.exist?(single_file) ? [single_file] : []
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
source_files.each do |file_path|
|
|
139
|
+
# Calculate relative path from project root
|
|
140
|
+
relative_path = file_path.sub("#{project_path}/", "")
|
|
141
|
+
dest_file = File.join(worktree_path, relative_path)
|
|
142
|
+
|
|
143
|
+
# Create destination directory if needed
|
|
144
|
+
FileUtils.mkdir_p(File.dirname(dest_file))
|
|
145
|
+
|
|
146
|
+
case strategy
|
|
147
|
+
when "copy"
|
|
148
|
+
FileUtils.cp(file_path, dest_file)
|
|
149
|
+
when "symlink"
|
|
150
|
+
FileUtils.ln_sf(file_path, dest_file)
|
|
151
|
+
end
|
|
152
|
+
end
|
|
95
153
|
end
|
|
96
154
|
|
|
97
155
|
def validate_rules(project_name)
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Sxn
|
|
6
|
+
module Core
|
|
7
|
+
# Manages .sxnrc session configuration files
|
|
8
|
+
class SessionConfig
|
|
9
|
+
FILENAME = ".sxnrc"
|
|
10
|
+
|
|
11
|
+
attr_reader :session_path, :config_path
|
|
12
|
+
|
|
13
|
+
def initialize(session_path)
|
|
14
|
+
@session_path = session_path
|
|
15
|
+
@config_path = File.join(session_path, FILENAME)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def create(parent_sxn_path:, default_branch:, session_name:, template_id: nil)
|
|
19
|
+
config = {
|
|
20
|
+
"version" => 1,
|
|
21
|
+
"parent_sxn_path" => parent_sxn_path,
|
|
22
|
+
"default_branch" => default_branch,
|
|
23
|
+
"session_name" => session_name,
|
|
24
|
+
"created_at" => Time.now.iso8601
|
|
25
|
+
}
|
|
26
|
+
config["template_id"] = template_id if template_id
|
|
27
|
+
File.write(@config_path, YAML.dump(config))
|
|
28
|
+
config
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def exists?
|
|
32
|
+
File.exist?(@config_path)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def read
|
|
36
|
+
return nil unless exists?
|
|
37
|
+
|
|
38
|
+
YAML.safe_load_file(@config_path) || {}
|
|
39
|
+
rescue Psych::SyntaxError
|
|
40
|
+
nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def parent_sxn_path
|
|
44
|
+
read&.dig("parent_sxn_path")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def default_branch
|
|
48
|
+
read&.dig("default_branch")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def session_name
|
|
52
|
+
read&.dig("session_name")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def template_id
|
|
56
|
+
read&.dig("template_id")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def project_root
|
|
60
|
+
parent_path = parent_sxn_path
|
|
61
|
+
return nil unless parent_path
|
|
62
|
+
|
|
63
|
+
# parent_sxn_path points to .sxn folder, project root is its parent
|
|
64
|
+
File.dirname(parent_path)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def update(updates)
|
|
68
|
+
config = read || {}
|
|
69
|
+
updates.each do |key, value|
|
|
70
|
+
config[key.to_s] = value
|
|
71
|
+
end
|
|
72
|
+
File.write(@config_path, YAML.dump(config))
|
|
73
|
+
config
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Class method: Find .sxnrc by walking up directory tree
|
|
77
|
+
def self.find_from_path(start_path)
|
|
78
|
+
current = File.expand_path(start_path)
|
|
79
|
+
|
|
80
|
+
while current != "/" && current != File.dirname(current)
|
|
81
|
+
config_path = File.join(current, FILENAME)
|
|
82
|
+
return new(current) if File.exist?(config_path)
|
|
83
|
+
|
|
84
|
+
current = File.dirname(current)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Class method: Check if path is within a session
|
|
91
|
+
def self.in_session?(path = Dir.pwd)
|
|
92
|
+
!find_from_path(path).nil?
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -13,7 +13,7 @@ module Sxn
|
|
|
13
13
|
@database = initialize_database
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
-
def create_session(name, description: nil, linear_task: nil)
|
|
16
|
+
def create_session(name, description: nil, linear_task: nil, default_branch: nil, template_id: nil)
|
|
17
17
|
validate_session_name!(name)
|
|
18
18
|
ensure_sessions_folder_exists!
|
|
19
19
|
|
|
@@ -21,11 +21,24 @@ module Sxn
|
|
|
21
21
|
|
|
22
22
|
session_id = SecureRandom.uuid
|
|
23
23
|
session_path = File.join(@config_manager.sessions_folder_path, name)
|
|
24
|
+
branch = default_branch || name
|
|
24
25
|
|
|
25
26
|
# Create session directory
|
|
26
27
|
FileUtils.mkdir_p(session_path)
|
|
27
28
|
|
|
28
|
-
# Create
|
|
29
|
+
# Create .sxnrc configuration file
|
|
30
|
+
session_config = SessionConfig.new(session_path)
|
|
31
|
+
session_config.create(
|
|
32
|
+
parent_sxn_path: @config_manager.sxn_folder_path,
|
|
33
|
+
default_branch: branch,
|
|
34
|
+
session_name: name,
|
|
35
|
+
template_id: template_id
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
# Create session record with template_id in metadata
|
|
39
|
+
metadata = {}
|
|
40
|
+
metadata["template_id"] = template_id if template_id
|
|
41
|
+
|
|
29
42
|
session_data = {
|
|
30
43
|
id: session_id,
|
|
31
44
|
name: name,
|
|
@@ -35,8 +48,10 @@ module Sxn
|
|
|
35
48
|
status: "active",
|
|
36
49
|
description: description,
|
|
37
50
|
linear_task: linear_task,
|
|
51
|
+
default_branch: branch,
|
|
38
52
|
projects: [],
|
|
39
|
-
worktrees: {}
|
|
53
|
+
worktrees: {},
|
|
54
|
+
metadata: metadata
|
|
40
55
|
}
|
|
41
56
|
|
|
42
57
|
@database.create_session(session_data)
|
|
@@ -162,6 +177,15 @@ module Sxn
|
|
|
162
177
|
session_data[:worktrees] || {}
|
|
163
178
|
end
|
|
164
179
|
|
|
180
|
+
def get_session_default_branch(session_name)
|
|
181
|
+
session = get_session(session_name)
|
|
182
|
+
return nil unless session
|
|
183
|
+
|
|
184
|
+
# Read from .sxnrc file in session directory
|
|
185
|
+
session_config = SessionConfig.new(session[:path])
|
|
186
|
+
session_config.default_branch
|
|
187
|
+
end
|
|
188
|
+
|
|
165
189
|
def archive_session(name)
|
|
166
190
|
update_session_status_by_name(name, "archived")
|
|
167
191
|
true
|
|
@@ -224,6 +248,8 @@ module Sxn
|
|
|
224
248
|
status: db_row[:status],
|
|
225
249
|
description: metadata["description"] || db_row[:description],
|
|
226
250
|
linear_task: metadata["linear_task"] || db_row[:linear_task],
|
|
251
|
+
default_branch: metadata["default_branch"] || db_row[:default_branch],
|
|
252
|
+
template_id: metadata["template_id"],
|
|
227
253
|
# Support both metadata and database columns for backward compatibility
|
|
228
254
|
projects: db_row[:projects] || metadata["projects"] || [],
|
|
229
255
|
worktrees: db_row[:worktrees] || metadata["worktrees"] || {}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../config/templates_config"
|
|
4
|
+
|
|
5
|
+
module Sxn
|
|
6
|
+
module Core
|
|
7
|
+
# Manages session templates - collections of projects that can be
|
|
8
|
+
# applied when creating a session to automatically create multiple worktrees.
|
|
9
|
+
class TemplateManager
|
|
10
|
+
attr_reader :config_manager, :templates_config
|
|
11
|
+
|
|
12
|
+
def initialize(config_manager)
|
|
13
|
+
@config_manager = config_manager
|
|
14
|
+
@templates_config = Config::TemplatesConfig.new(config_manager.sxn_folder_path)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# List all available templates
|
|
18
|
+
# @return [Array<Hash>] Array of template info hashes
|
|
19
|
+
def list_templates
|
|
20
|
+
config = templates_config.load
|
|
21
|
+
templates = config["templates"] || {}
|
|
22
|
+
|
|
23
|
+
templates.map do |name, template|
|
|
24
|
+
{
|
|
25
|
+
name: name,
|
|
26
|
+
description: template["description"],
|
|
27
|
+
project_count: (template["projects"] || []).size
|
|
28
|
+
}
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Get a specific template by name
|
|
33
|
+
# @param name [String] Template name
|
|
34
|
+
# @return [Hash] Template configuration
|
|
35
|
+
# @raise [SessionTemplateNotFoundError] If template not found
|
|
36
|
+
def get_template(name)
|
|
37
|
+
template = templates_config.get_template(name)
|
|
38
|
+
|
|
39
|
+
unless template
|
|
40
|
+
available = list_template_names
|
|
41
|
+
raise SessionTemplateNotFoundError.new(name, available: available)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
template
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Get list of template names
|
|
48
|
+
# @return [Array<String>] Template names
|
|
49
|
+
def list_template_names
|
|
50
|
+
templates_config.list_template_names
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Validate a template before use
|
|
54
|
+
# @param name [String] Template name
|
|
55
|
+
# @raise [SessionTemplateValidationError] If template is invalid
|
|
56
|
+
# @raise [SessionTemplateNotFoundError] If template not found
|
|
57
|
+
def validate_template(name)
|
|
58
|
+
template = get_template(name)
|
|
59
|
+
projects = template["projects"] || []
|
|
60
|
+
errors = []
|
|
61
|
+
|
|
62
|
+
errors << "Template has no projects defined" if projects.empty?
|
|
63
|
+
|
|
64
|
+
# Validate each project exists in config
|
|
65
|
+
projects.each do |project_config|
|
|
66
|
+
project_name = project_config["name"]
|
|
67
|
+
project = config_manager.get_project(project_name)
|
|
68
|
+
|
|
69
|
+
errors << "Project '#{project_name}' not found in configuration" unless project
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
raise SessionTemplateValidationError.new(name, errors.join("; ")) if errors.any?
|
|
73
|
+
|
|
74
|
+
true
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Create a new template
|
|
78
|
+
# @param name [String] Template name
|
|
79
|
+
# @param description [String] Template description
|
|
80
|
+
# @param projects [Array<String>] Array of project names
|
|
81
|
+
# @return [Hash] Created template
|
|
82
|
+
def create_template(name, description: nil, projects: [])
|
|
83
|
+
validate_template_name!(name)
|
|
84
|
+
|
|
85
|
+
raise SessionTemplateValidationError.new(name, "Template already exists") if template_exists?(name)
|
|
86
|
+
|
|
87
|
+
# Validate all projects exist
|
|
88
|
+
projects.each do |project_name|
|
|
89
|
+
project = config_manager.get_project(project_name)
|
|
90
|
+
raise SessionTemplateValidationError.new(name, "Project '#{project_name}' not found") unless project
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
template = {
|
|
94
|
+
"description" => description,
|
|
95
|
+
"projects" => projects.map { |p| { "name" => p } }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
templates_config.set_template(name, template)
|
|
99
|
+
|
|
100
|
+
get_template(name)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Update an existing template
|
|
104
|
+
# @param name [String] Template name
|
|
105
|
+
# @param description [String, nil] New description
|
|
106
|
+
# @param projects [Array<String>, nil] New project list
|
|
107
|
+
# @return [Hash] Updated template
|
|
108
|
+
def update_template(name, description: nil, projects: nil)
|
|
109
|
+
template = get_template(name)
|
|
110
|
+
|
|
111
|
+
# Update description if provided
|
|
112
|
+
template["description"] = description if description
|
|
113
|
+
|
|
114
|
+
# Update projects if provided
|
|
115
|
+
if projects
|
|
116
|
+
# Validate all projects exist
|
|
117
|
+
projects.each do |project_name|
|
|
118
|
+
project = config_manager.get_project(project_name)
|
|
119
|
+
raise SessionTemplateValidationError.new(name, "Project '#{project_name}' not found") unless project
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
template["projects"] = projects.map { |p| { "name" => p } }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Save back to config
|
|
126
|
+
config = templates_config.load
|
|
127
|
+
config["templates"] ||= {}
|
|
128
|
+
config["templates"][name] = {
|
|
129
|
+
"description" => template["description"],
|
|
130
|
+
"projects" => template["projects"]
|
|
131
|
+
}
|
|
132
|
+
templates_config.save(config)
|
|
133
|
+
|
|
134
|
+
get_template(name)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Remove a template
|
|
138
|
+
# @param name [String] Template name
|
|
139
|
+
# @return [Boolean] True if removed
|
|
140
|
+
def remove_template(name)
|
|
141
|
+
# Verify template exists
|
|
142
|
+
get_template(name)
|
|
143
|
+
|
|
144
|
+
templates_config.remove_template(name)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Check if a template exists
|
|
148
|
+
# @param name [String] Template name
|
|
149
|
+
# @return [Boolean]
|
|
150
|
+
def template_exists?(name)
|
|
151
|
+
templates_config.get_template(name) != nil
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Get project configurations for a template with branch resolution
|
|
155
|
+
# @param name [String] Template name
|
|
156
|
+
# @param default_branch [String] Default branch to use if not specified per-project
|
|
157
|
+
# @return [Array<Hash>] Array of project configs with resolved branches
|
|
158
|
+
def get_template_projects(name, default_branch:)
|
|
159
|
+
template = get_template(name)
|
|
160
|
+
projects = template["projects"] || []
|
|
161
|
+
|
|
162
|
+
projects.map do |project_config|
|
|
163
|
+
project_name = project_config["name"]
|
|
164
|
+
project = config_manager.get_project(project_name)
|
|
165
|
+
|
|
166
|
+
{
|
|
167
|
+
name: project_name,
|
|
168
|
+
path: project[:path],
|
|
169
|
+
branch: project_config["branch"] || default_branch,
|
|
170
|
+
rules: project_config["rules"]
|
|
171
|
+
}
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
private
|
|
176
|
+
|
|
177
|
+
def validate_template_name!(name)
|
|
178
|
+
return if name.match?(/\A[a-zA-Z0-9_-]+\z/)
|
|
179
|
+
|
|
180
|
+
raise SessionTemplateValidationError.new(
|
|
181
|
+
name,
|
|
182
|
+
"Template name must contain only letters, numbers, hyphens, and underscores"
|
|
183
|
+
)
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|