sxn 0.2.2 → 0.2.5

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.
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "thor"
4
4
  require "time"
5
+ require "shellwords"
5
6
 
6
7
  module Sxn
7
8
  module Commands
@@ -16,12 +17,16 @@ module Sxn
16
17
  @table = Sxn::UI::Table.new
17
18
  @config_manager = Sxn::Core::ConfigManager.new
18
19
  @session_manager = Sxn::Core::SessionManager.new(@config_manager)
20
+ @project_manager = Sxn::Core::ProjectManager.new(@config_manager)
21
+ @worktree_manager = Sxn::Core::WorktreeManager.new(@config_manager, @session_manager)
19
22
  end
20
23
 
21
24
  desc "add NAME", "Create a new session"
22
25
  option :description, type: :string, aliases: "-d", desc: "Session description"
23
26
  option :linear_task, type: :string, aliases: "-l", desc: "Linear task ID"
27
+ option :branch, type: :string, aliases: "-b", desc: "Default branch for worktrees"
24
28
  option :activate, type: :boolean, default: true, desc: "Activate session after creation"
29
+ option :skip_worktree, type: :boolean, default: false, desc: "Skip worktree creation wizard"
25
30
 
26
31
  def add(name = nil)
27
32
  ensure_initialized!
@@ -32,24 +37,31 @@ module Sxn
32
37
  name = @prompt.session_name(existing_sessions: existing_sessions)
33
38
  end
34
39
 
40
+ # Get default branch - use provided option, or prompt interactively
41
+ default_branch = options[:branch]
42
+ default_branch ||= @prompt.default_branch(session_name: name)
43
+
35
44
  begin
36
45
  @ui.progress_start("Creating session '#{name}'")
37
46
 
38
47
  session = @session_manager.create_session(
39
48
  name,
40
49
  description: options[:description],
41
- linear_task: options[:linear_task]
50
+ linear_task: options[:linear_task],
51
+ default_branch: default_branch
42
52
  )
43
53
 
44
54
  @ui.progress_done
45
55
  @ui.success("Created session '#{name}'")
46
56
 
47
- if options[:activate]
48
- @session_manager.use_session(name)
49
- @ui.success("Activated session '#{name}'")
50
- end
57
+ # Always activate the new session (this is the expected behavior)
58
+ @session_manager.use_session(name)
59
+ @ui.success("Switched to session '#{name}'")
51
60
 
52
61
  display_session_info(session)
62
+
63
+ # Offer to add a worktree unless skipped
64
+ offer_worktree_wizard(name) unless options[:skip_worktree]
53
65
  rescue Sxn::Error => e
54
66
  @ui.progress_failed
55
67
  @ui.error(e.message)
@@ -168,12 +180,23 @@ module Sxn
168
180
  end
169
181
  end
170
182
 
171
- desc "current", "Show current session"
183
+ desc "current [SUBCOMMAND]", "Show current session or enter its directory"
172
184
  option :verbose, type: :boolean, aliases: "-v", desc: "Show detailed information"
185
+ option :path, type: :boolean, aliases: "-p", desc: "Output only the session path (for shell integration)"
173
186
 
174
- def current
187
+ def current(subcommand = nil)
175
188
  ensure_initialized!
176
189
 
190
+ # Handle 'sxn current enter' subcommand
191
+ if subcommand == "enter"
192
+ enter_session
193
+ return
194
+ elsif subcommand
195
+ @ui.error("Unknown subcommand: #{subcommand}")
196
+ @ui.info("Available: enter")
197
+ exit(1)
198
+ end
199
+
177
200
  begin
178
201
  session = @session_manager.current_session
179
202
 
@@ -183,6 +206,12 @@ module Sxn
183
206
  return
184
207
  end
185
208
 
209
+ # If --path flag, just output the path for shell integration
210
+ if options[:path]
211
+ puts session[:path]
212
+ return
213
+ end
214
+
186
215
  @ui.section("Current Session")
187
216
  display_session_info(session, verbose: options[:verbose])
188
217
  rescue Sxn::Error => e
@@ -191,6 +220,11 @@ module Sxn
191
220
  end
192
221
  end
193
222
 
223
+ desc "enter", "Enter the current session directory (outputs cd command)"
224
+ def enter
225
+ enter_session
226
+ end
227
+
194
228
  desc "archive NAME", "Archive a session"
195
229
  def archive(name = nil)
196
230
  ensure_initialized!
@@ -256,6 +290,7 @@ module Sxn
256
290
  @ui.key_value("Name", session[:name] || "Unknown")
257
291
  @ui.key_value("Status", (session[:status] || "unknown").capitalize)
258
292
  @ui.key_value("Path", session[:path] || "Unknown")
293
+ @ui.key_value("Default Branch", session[:default_branch]) if session[:default_branch]
259
294
 
260
295
  @ui.key_value("Description", session[:description]) if session[:description]
261
296
 
@@ -310,6 +345,161 @@ module Sxn
310
345
  rescue ArgumentError
311
346
  timestamp # Return original if parsing fails
312
347
  end
348
+
349
+ def offer_worktree_wizard(session_name)
350
+ @ui.newline
351
+ @ui.section("Add Worktree")
352
+
353
+ # Check if there are any projects configured
354
+ projects = @project_manager.list_projects
355
+ if projects.empty?
356
+ @ui.info("No projects configured yet.")
357
+ @ui.recovery_suggestion("Add projects with 'sxn projects add <name> <path>'")
358
+ return
359
+ end
360
+
361
+ # Ask if user wants to add a worktree
362
+ return unless @prompt.ask_yes_no("Would you like to add a worktree to this session?", default: true)
363
+
364
+ # Step 1: Select project with descriptions
365
+ @ui.newline
366
+ @ui.info("A worktree is a linked copy of a project that allows you to work on a branch")
367
+ @ui.info("without affecting the main repository. Select which project to add:")
368
+ @ui.newline
369
+
370
+ project_choices = projects.map do |p|
371
+ {
372
+ name: "#{p[:name]} (#{p[:type]}) - #{p[:path]}",
373
+ value: p[:name]
374
+ }
375
+ end
376
+ project_name = @prompt.select("Select project:", project_choices)
377
+
378
+ # Step 2: Get branch name with explanation
379
+ # Get session's default branch for the default value
380
+ session = @session_manager.get_session(session_name)
381
+ default_branch_for_worktree = session[:default_branch] || session_name
382
+
383
+ @ui.newline
384
+ @ui.info("Enter the branch name for this worktree.")
385
+ @ui.info("This can be an existing branch or a new one to create.")
386
+ @ui.info("Tip: Use 'remote:<branch>' to track a remote branch (e.g., 'remote:origin/main')")
387
+ @ui.newline
388
+
389
+ branch = @prompt.branch_name(
390
+ "Branch name:",
391
+ default: default_branch_for_worktree
392
+ )
393
+
394
+ # Step 3: Create the worktree
395
+ create_worktree_for_session(project_name, branch, session_name)
396
+
397
+ # Offer to add more worktrees
398
+ add_more_worktrees(session_name)
399
+ end
400
+
401
+ def create_worktree_for_session(project_name, branch, session_name)
402
+ @ui.newline
403
+ @ui.progress_start("Creating worktree for #{project_name}")
404
+
405
+ worktree = @worktree_manager.add_worktree(
406
+ project_name,
407
+ branch,
408
+ session_name: session_name
409
+ )
410
+
411
+ @ui.progress_done
412
+ @ui.success("Created worktree for #{project_name}")
413
+
414
+ display_worktree_info(worktree)
415
+
416
+ # Apply rules
417
+ apply_project_rules(project_name, session_name)
418
+ rescue Sxn::Error => e
419
+ @ui.progress_failed
420
+ @ui.error(e.message)
421
+ @ui.recovery_suggestion("You can try again with 'sxn worktree add #{project_name}'")
422
+ end
423
+
424
+ def add_more_worktrees(session_name)
425
+ projects = @project_manager.list_projects
426
+ return if projects.empty?
427
+
428
+ # Get session's default branch for the default value
429
+ session = @session_manager.get_session(session_name)
430
+ default_branch_for_worktree = session[:default_branch] || session_name
431
+
432
+ while @prompt.ask_yes_no("Would you like to add another worktree?", default: false)
433
+ @ui.newline
434
+
435
+ project_choices = projects.map do |p|
436
+ {
437
+ name: "#{p[:name]} (#{p[:type]}) - #{p[:path]}",
438
+ value: p[:name]
439
+ }
440
+ end
441
+ project_name = @prompt.select("Select project:", project_choices)
442
+
443
+ branch = @prompt.branch_name(
444
+ "Branch name:",
445
+ default: default_branch_for_worktree
446
+ )
447
+
448
+ create_worktree_for_session(project_name, branch, session_name)
449
+ end
450
+ end
451
+
452
+ def display_worktree_info(worktree)
453
+ @ui.newline
454
+ @ui.key_value("Project", worktree[:project])
455
+ @ui.key_value("Branch", worktree[:branch])
456
+ @ui.key_value("Path", worktree[:path])
457
+ end
458
+
459
+ def enter_session
460
+ session = @session_manager.current_session
461
+
462
+ if session.nil?
463
+ # When no session, provide helpful guidance instead of just a cd command
464
+ warn "No active session. Use 'sxn use <session>' to activate a session first."
465
+ warn ""
466
+ warn "Tip: Add this function to your shell profile for easier navigation:"
467
+ warn ""
468
+ warn " sxn-enter() { eval \"$(sxn enter 2>/dev/null)\" || sxn enter; }"
469
+ warn ""
470
+ exit(1)
471
+ end
472
+
473
+ session_path = session[:path]
474
+
475
+ unless session_path && File.directory?(session_path)
476
+ warn "Session directory does not exist: #{session_path || "nil"}"
477
+ exit(1)
478
+ end
479
+
480
+ # Output the cd command for shell integration
481
+ # Users can use: eval "$(sxn enter)" or the sxn-enter shell function
482
+ puts "cd #{Shellwords.escape(session_path)}"
483
+ rescue Sxn::Error => e
484
+ warn e.message
485
+ exit(e.exit_code)
486
+ end
487
+
488
+ def apply_project_rules(project_name, session_name)
489
+ rules_manager = Sxn::Core::RulesManager.new(@config_manager, @project_manager)
490
+
491
+ @ui.progress_start("Applying rules for #{project_name}")
492
+ results = rules_manager.apply_rules(project_name, session_name)
493
+ @ui.progress_done
494
+
495
+ if results[:success]
496
+ @ui.success("Applied #{results[:applied_count]} rules") if results[:applied_count].positive?
497
+ else
498
+ @ui.warning("Some rules failed to apply")
499
+ end
500
+ rescue StandardError => e
501
+ @ui.warning("Could not apply rules: #{e.message}")
502
+ end
313
503
  end
314
504
  end
315
505
  end
@@ -19,10 +19,29 @@ module Sxn
19
19
  @worktree_manager = Sxn::Core::WorktreeManager.new(@config_manager, @session_manager)
20
20
  end
21
21
 
22
- desc "add PROJECT [BRANCH]", "Add worktree to current session"
22
+ desc "add PROJECT [BRANCH]", "Add worktree to current session (defaults branch to session name)"
23
+ long_desc <<-DESC
24
+ Add a worktree for a project to the current session.
25
+
26
+ Branch options:
27
+ - No branch specified: Uses the session name as the branch name
28
+ - Branch name: Creates or checks out the specified branch
29
+ - remote:<branch>: Fetches and tracks the remote branch
30
+
31
+ Examples:
32
+ - sxn worktree add atlas-core
33
+ Creates worktree with branch name matching current session
34
+
35
+ - sxn worktree add atlas-core feature-branch
36
+ Creates worktree with specified branch name
37
+
38
+ - sxn worktree add atlas-core remote:origin/main
39
+ Fetches and tracks the remote branch
40
+ DESC
23
41
  option :session, type: :string, aliases: "-s", desc: "Target session (defaults to current)"
24
42
  option :apply_rules, type: :boolean, default: true, desc: "Apply project rules after creation"
25
43
  option :interactive, type: :boolean, aliases: "-i", desc: "Interactive mode"
44
+ option :verbose, type: :boolean, aliases: "-v", desc: "Show detailed output for debugging"
26
45
 
27
46
  def add(project_name = nil, branch = nil)
28
47
  ensure_initialized!
@@ -33,10 +52,11 @@ module Sxn
33
52
  return if project_name.nil?
34
53
  end
35
54
 
36
- # Interactive branch selection if not provided
55
+ # Interactive branch selection if not provided and interactive mode
56
+ # Note: If branch is nil, WorktreeManager will use session name as default
37
57
  if options[:interactive] && branch.nil?
38
- project = @project_manager.get_project(project_name)
39
- branch = @prompt.branch_name("Enter branch name:", default: project[:default_branch])
58
+ session_name = options[:session] || @config_manager.current_session
59
+ branch = @prompt.branch_name("Enter branch name:", default: session_name)
40
60
  end
41
61
 
42
62
  session_name = options[:session] || @config_manager.current_session
@@ -47,12 +67,16 @@ module Sxn
47
67
  end
48
68
 
49
69
  begin
70
+ # Enable debug mode if verbose flag is set
71
+ ENV["SXN_DEBUG"] = "true" if options[:verbose]
72
+
50
73
  @ui.progress_start("Creating worktree for #{project_name}")
51
74
 
52
75
  worktree = @worktree_manager.add_worktree(
53
76
  project_name,
54
77
  branch,
55
- session_name: session_name
78
+ session_name: session_name,
79
+ verbose: options[:verbose]
56
80
  )
57
81
 
58
82
  @ui.progress_done
@@ -65,7 +89,18 @@ module Sxn
65
89
  rescue Sxn::Error => e
66
90
  @ui.progress_failed
67
91
  @ui.error(e.message)
92
+
93
+ if options[:verbose] && e.respond_to?(:details)
94
+ @ui.newline
95
+ @ui.subsection("Debug Information")
96
+ @ui.info(e.details)
97
+ elsif !options[:verbose]
98
+ @ui.recovery_suggestion("Run with --verbose flag for more details")
99
+ end
100
+
68
101
  exit(e.exit_code)
102
+ ensure
103
+ ENV.delete("SXN_DEBUG") if options[:verbose]
69
104
  end
70
105
  end
71
106
 
@@ -3,6 +3,7 @@
3
3
  require "fileutils"
4
4
  require "yaml"
5
5
  require "pathname"
6
+ require "ostruct"
6
7
 
7
8
  module Sxn
8
9
  module Core
@@ -40,7 +41,10 @@ module Sxn
40
41
  raise Sxn::ConfigurationError, "Project not initialized. Run 'sxn init' first." unless initialized?
41
42
 
42
43
  discovery = Sxn::Config::ConfigDiscovery.new(@base_path)
43
- discovery.discover_config
44
+ config_hash = discovery.discover_config
45
+
46
+ # Convert nested hashes to OpenStruct recursively
47
+ config_to_struct(config_hash)
44
48
  end
45
49
 
46
50
  def update_current_session(session_name)
@@ -58,6 +62,10 @@ module Sxn
58
62
  @sessions_folder || (load_config && @sessions_folder)
59
63
  end
60
64
 
65
+ def sxn_folder_path
66
+ File.dirname(@config_path)
67
+ end
68
+
61
69
  def add_project(name, path, type: nil, default_branch: nil)
62
70
  config = load_config_file
63
71
  config["projects"] ||= {}
@@ -194,6 +202,14 @@ module Sxn
194
202
 
195
203
  private
196
204
 
205
+ def config_to_struct(obj)
206
+ return obj unless obj.is_a?(Hash)
207
+
208
+ OpenStruct.new(
209
+ obj.transform_values { |v| config_to_struct(v) }
210
+ )
211
+ end
212
+
197
213
  def sessions_folder_relative_path
198
214
  return ".sxn" unless @sessions_folder
199
215
 
@@ -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,91 @@
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:)
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
+ File.write(@config_path, YAML.dump(config))
27
+ config
28
+ end
29
+
30
+ def exists?
31
+ File.exist?(@config_path)
32
+ end
33
+
34
+ def read
35
+ return nil unless exists?
36
+
37
+ YAML.safe_load_file(@config_path) || {}
38
+ rescue Psych::SyntaxError
39
+ nil
40
+ end
41
+
42
+ def parent_sxn_path
43
+ read&.dig("parent_sxn_path")
44
+ end
45
+
46
+ def default_branch
47
+ read&.dig("default_branch")
48
+ end
49
+
50
+ def session_name
51
+ read&.dig("session_name")
52
+ end
53
+
54
+ def project_root
55
+ parent_path = parent_sxn_path
56
+ return nil unless parent_path
57
+
58
+ # parent_sxn_path points to .sxn folder, project root is its parent
59
+ File.dirname(parent_path)
60
+ end
61
+
62
+ def update(updates)
63
+ config = read || {}
64
+ updates.each do |key, value|
65
+ config[key.to_s] = value
66
+ end
67
+ File.write(@config_path, YAML.dump(config))
68
+ config
69
+ end
70
+
71
+ # Class method: Find .sxnrc by walking up directory tree
72
+ def self.find_from_path(start_path)
73
+ current = File.expand_path(start_path)
74
+
75
+ while current != "/" && current != File.dirname(current)
76
+ config_path = File.join(current, FILENAME)
77
+ return new(current) if File.exist?(config_path)
78
+
79
+ current = File.dirname(current)
80
+ end
81
+
82
+ nil
83
+ end
84
+
85
+ # Class method: Check if path is within a session
86
+ def self.in_session?(path = Dir.pwd)
87
+ !find_from_path(path).nil?
88
+ end
89
+ end
90
+ end
91
+ 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)
17
17
  validate_session_name!(name)
18
18
  ensure_sessions_folder_exists!
19
19
 
@@ -21,10 +21,19 @@ 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
 
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
+ )
36
+
28
37
  # Create session record
29
38
  session_data = {
30
39
  id: session_id,
@@ -35,6 +44,7 @@ module Sxn
35
44
  status: "active",
36
45
  description: description,
37
46
  linear_task: linear_task,
47
+ default_branch: branch,
38
48
  projects: [],
39
49
  worktrees: {}
40
50
  }
@@ -162,6 +172,15 @@ module Sxn
162
172
  session_data[:worktrees] || {}
163
173
  end
164
174
 
175
+ def get_session_default_branch(session_name)
176
+ session = get_session(session_name)
177
+ return nil unless session
178
+
179
+ # Read from .sxnrc file in session directory
180
+ session_config = SessionConfig.new(session[:path])
181
+ session_config.default_branch
182
+ end
183
+
165
184
  def archive_session(name)
166
185
  update_session_status_by_name(name, "archived")
167
186
  true
@@ -224,6 +243,7 @@ module Sxn
224
243
  status: db_row[:status],
225
244
  description: metadata["description"] || db_row[:description],
226
245
  linear_task: metadata["linear_task"] || db_row[:linear_task],
246
+ default_branch: metadata["default_branch"] || db_row[:default_branch],
227
247
  # Support both metadata and database columns for backward compatibility
228
248
  projects: db_row[:projects] || metadata["projects"] || [],
229
249
  worktrees: db_row[:worktrees] || metadata["worktrees"] || {}