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.
@@ -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
@@ -3,6 +3,7 @@
3
3
  require_relative "config/config_discovery"
4
4
  require_relative "config/config_cache"
5
5
  require_relative "config/config_validator"
6
+ require_relative "config/templates_config"
6
7
 
7
8
  module Sxn
8
9
  module Config
@@ -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
- rules = project_config&.dig("rules") || {}
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
- # Apply rules to worktree
94
- @rules_engine.apply_rules(rules)
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 session record
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