sxn 0.2.3 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6c2adba72c82bfd3b3b92c29bf497cc0c486b16261edc38c103135a7391525a4
4
- data.tar.gz: fa079e0580d51496bf4d5f4af242b291847854a1d6dd6f17918730b463bb2c9d
3
+ metadata.gz: 30ceb6fb94a023b31e9b9c4c4354806a5343b7cefa8f1b43047011f86e3048cf
4
+ data.tar.gz: 071b888de74d9e284376acf2ed425a5a33ddf329ac01112bc3fbc65acc8dd363
5
5
  SHA512:
6
- metadata.gz: d8a67b4261fb520b7b64c3c039dcabd5f47f1ae6ca377d1fa4d0a6d0dd9ac2574a83cc0024923273a754527c9e0cba96c75e0923918d1fd997ba62f8f1dd6ae7
7
- data.tar.gz: 79218840d95ce17d7a62d40e2f4cfca09498973b784677175ec1cbf67a9ed801074fad18a716dba2a16f89c7499a030b53e3c9c5fbf1b5f412b7cbd0530cd628
6
+ metadata.gz: 93cb3f85f80ccc13ffcc62662fa86bd0e8b7531105ab08dc49f0213744a858bbaf7fff8823c2d5358617704eeb5aae6f2264ba02f20e14c71003fd8e912136d4
7
+ data.tar.gz: fc1e076616d354218525d2d8ee6a6454a04dfd5d1db969f7032792c91ec1679605a4d0351573a57cd3b5af64c8d06fdecad1068b51dfd66bc2bea5c92bc52dfc
data/CHANGELOG.md CHANGED
@@ -5,6 +5,36 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.2.5] - 2025-11-30
9
+
10
+ ### Added
11
+ - `sxn enter` command to quickly navigate to current session directory
12
+ - `sxn current enter` subcommand as alternative way to enter session
13
+ - `--path` option for `sxn current` to output only the session path
14
+ - `sxn shell` command to install shell integration (idempotent)
15
+ - Auto-detects shell type (bash/zsh)
16
+ - Installs `sxn-enter` function to shell config
17
+ - Supports `--uninstall` to remove integration
18
+ - Supports `--shell-type` to specify shell explicitly
19
+
20
+ ## [0.2.4] - 2025-11-30
21
+
22
+ ### Added
23
+ - Interactive worktree wizard after session creation
24
+ - Prompts to add worktrees with descriptive explanations
25
+ - Supports adding multiple worktrees in sequence
26
+ - Explains branch options including remote tracking syntax
27
+ - `--skip-worktree` flag to bypass the wizard when creating sessions
28
+ - `--verbose` flag for worktree debugging with detailed git output
29
+
30
+ ### Changed
31
+ - Sessions now automatically switch to newly created session (no need to run `sxn use` afterwards)
32
+ - Improved project manager to safely handle nil projects configuration
33
+
34
+ ### Fixed
35
+ - Fixed test mocks for verbose parameter in worktree operations
36
+ - Fixed version spec to support semver pre-release format
37
+
8
38
  ## [0.2.3] - 2025-09-16
9
39
 
10
40
  ### Added
@@ -71,6 +101,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
71
101
  - Initial placeholder release
72
102
  - Basic gem structure
73
103
 
104
+ [0.2.4]: https://github.com/idl3/sxn/compare/v0.2.3...v0.2.4
74
105
  [0.2.3]: https://github.com/idl3/sxn/compare/v0.2.1...v0.2.3
75
106
  [0.2.1]: https://github.com/idl3/sxn/compare/v0.2.0...v0.2.1
76
107
  [0.2.0]: https://github.com/idl3/sxn/compare/v0.1.0...v0.2.0
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- sxn (0.2.3)
4
+ sxn (0.2.5)
5
5
  async (~> 2.0)
6
6
  bcrypt (~> 3.1)
7
7
  dry-configurable (~> 1.0)
data/lib/sxn/CLI.rb CHANGED
@@ -41,8 +41,11 @@ module Sxn
41
41
  desc "add SESSION_NAME", "Create a new session (shortcut for 'sxn sessions add')"
42
42
  option :description, type: :string, aliases: "-d", desc: "Session description"
43
43
  option :linear_task, type: :string, aliases: "-l", desc: "Linear task ID"
44
+ option :branch, type: :string, aliases: "-b", desc: "Default branch for worktrees"
44
45
  def add(session_name)
45
- Commands::Sessions.new.add(session_name)
46
+ cmd = Commands::Sessions.new
47
+ cmd.options = options
48
+ cmd.add(session_name)
46
49
  rescue Sxn::Error => e
47
50
  handle_error(e)
48
51
  end
@@ -62,10 +65,102 @@ module Sxn
62
65
  handle_error(e)
63
66
  end
64
67
 
65
- desc "current", "Show current session (shortcut for 'sxn sessions current')"
68
+ desc "current [SUBCOMMAND]", "Show current session (shortcut for 'sxn sessions current')"
66
69
  option :verbose, type: :boolean, aliases: "-v", desc: "Show detailed information"
67
- def current
68
- Commands::Sessions.new.current
70
+ option :path, type: :boolean, aliases: "-p", desc: "Output only the session path"
71
+ def current(subcommand = nil)
72
+ Commands::Sessions.new.current(subcommand)
73
+ rescue Sxn::Error => e
74
+ handle_error(e)
75
+ end
76
+
77
+ desc "enter", "Enter current session directory (outputs cd command for shell eval)"
78
+ long_desc <<-LONGDESC
79
+ Outputs a cd command to navigate to the current session directory.
80
+
81
+ Usage with shell eval:
82
+ eval "$(sxn enter)"
83
+
84
+ Or install shell integration for easier use:
85
+ sxn shell
86
+
87
+ Then simply run:
88
+ sxn-enter
89
+ LONGDESC
90
+ def enter
91
+ Commands::Sessions.new.enter
92
+ rescue Sxn::Error => e
93
+ handle_error(e)
94
+ end
95
+
96
+ desc "up", "Navigate to project root from session (outputs cd command for shell eval)"
97
+ long_desc <<-LONGDESC
98
+ Outputs a cd command to navigate to the project root from within a session.
99
+
100
+ The project root is determined from the .sxnrc file in the session directory,
101
+ which points back to the parent .sxn folder.
102
+
103
+ Usage with shell eval:
104
+ eval "$(sxn up)"
105
+
106
+ Or install shell integration for easier use:
107
+ sxn shell
108
+
109
+ Then simply run:
110
+ sxn-up
111
+ LONGDESC
112
+ def up
113
+ require "shellwords"
114
+
115
+ session_config = Sxn::Core::SessionConfig.find_from_path(Dir.pwd)
116
+
117
+ unless session_config
118
+ warn "Not in a session directory."
119
+ warn ""
120
+ warn "This command works when run from within a session folder."
121
+ warn "Session folders contain a .sxnrc file that points back to the project."
122
+ warn ""
123
+ warn "Tip: Add this function to your shell profile for easier navigation:"
124
+ warn ""
125
+ warn " sxn-up() { eval \"$(sxn up 2>/dev/null)\" || sxn up; }"
126
+ exit(1)
127
+ end
128
+
129
+ project_root = session_config.project_root
130
+
131
+ unless project_root && File.directory?(project_root)
132
+ warn "Could not determine project root from .sxnrc"
133
+ warn "parent_sxn_path: #{session_config.parent_sxn_path || "nil"}"
134
+ exit(1)
135
+ end
136
+
137
+ # Output the cd command for shell integration
138
+ puts "cd #{Shellwords.escape(project_root)}"
139
+ rescue Sxn::Error => e
140
+ handle_error(e)
141
+ end
142
+
143
+ desc "shell", "Install shell integration (sxn-enter function)"
144
+ option :shell_type, type: :string, enum: %w[bash zsh auto], default: "auto",
145
+ desc: "Shell type (bash, zsh, or auto-detect)"
146
+ option :uninstall, type: :boolean, default: false, desc: "Remove shell integration"
147
+ long_desc <<-LONGDESC
148
+ Installs shell integration to your shell configuration file (.zshrc or .bashrc).
149
+
150
+ This adds the sxn-enter function which allows you to quickly navigate
151
+ to your current session directory.
152
+
153
+ The installation is idempotent - running it multiple times will not
154
+ add duplicate entries.
155
+
156
+ Examples:
157
+ sxn shell # Auto-detect shell and install
158
+ sxn shell --shell-type=zsh # Install for zsh specifically
159
+ sxn shell --uninstall # Remove shell integration
160
+ LONGDESC
161
+ map "shell" => :install_shell_wrapper
162
+ def install_shell_wrapper
163
+ Commands::Init.new.invoke(:install_shell, [], options)
69
164
  rescue Sxn::Error => e
70
165
  handle_error(e)
71
166
  end
@@ -8,6 +8,34 @@ module Sxn
8
8
  class Init < Thor
9
9
  include Thor::Actions
10
10
 
11
+ # Shell integration marker - used to identify sxn shell functions
12
+ SHELL_MARKER = "# sxn shell integration"
13
+ SHELL_MARKER_END = "# end sxn shell integration"
14
+
15
+ # Shell function that gets installed
16
+ SHELL_FUNCTION = <<~SHELL.freeze
17
+ #{SHELL_MARKER}
18
+ sxn-enter() {
19
+ local cmd
20
+ cmd="$(sxn enter 2>/dev/null)"
21
+ if [ $? -eq 0 ] && [ -n "$cmd" ]; then
22
+ eval "$cmd"
23
+ else
24
+ sxn enter
25
+ fi
26
+ }
27
+ sxn-up() {
28
+ local cmd
29
+ cmd="$(sxn up 2>/dev/null)"
30
+ if [ $? -eq 0 ] && [ -n "$cmd" ]; then
31
+ eval "$cmd"
32
+ else
33
+ sxn up
34
+ fi
35
+ }
36
+ #{SHELL_MARKER_END}
37
+ SHELL
38
+
11
39
  desc "init [FOLDER]", "Initialize sxn in a project folder"
12
40
  option :force, type: :boolean, desc: "Force initialization even if already initialized"
13
41
  option :auto_detect, type: :boolean, default: true, desc: "Automatically detect and register projects"
@@ -55,8 +83,116 @@ module Sxn
55
83
  end
56
84
  end
57
85
 
86
+ desc "install_shell", "Install shell integration (sxn-enter function)"
87
+ option :shell_type, type: :string, enum: %w[bash zsh auto], default: "auto",
88
+ desc: "Shell type (bash, zsh, or auto-detect)"
89
+ option :uninstall, type: :boolean, default: false, desc: "Remove shell integration"
90
+ def install_shell
91
+ @ui.section("Shell Integration")
92
+
93
+ shell_type = detect_shell_type
94
+ rc_file = shell_rc_file(shell_type)
95
+
96
+ unless rc_file
97
+ @ui.error("Could not determine shell configuration file")
98
+ @ui.info("Supported shells: bash, zsh")
99
+ exit(1)
100
+ end
101
+
102
+ if options[:uninstall]
103
+ uninstall_shell_integration(rc_file, shell_type)
104
+ else
105
+ install_shell_integration(rc_file, shell_type)
106
+ end
107
+ end
108
+
58
109
  private
59
110
 
111
+ def detect_shell_type
112
+ shell_opt = options[:shell_type] || options[:shell] || "auto"
113
+ return shell_opt unless shell_opt == "auto"
114
+
115
+ # Check SHELL environment variable
116
+ current_shell = ENV.fetch("SHELL", "")
117
+ if current_shell.include?("zsh")
118
+ "zsh"
119
+ elsif current_shell.include?("bash")
120
+ "bash"
121
+ else
122
+ # Default to bash
123
+ "bash"
124
+ end
125
+ end
126
+
127
+ def shell_rc_file(shell_type)
128
+ home = Dir.home
129
+ case shell_type
130
+ when "zsh"
131
+ File.join(home, ".zshrc")
132
+ when "bash"
133
+ # Prefer .bashrc, fall back to .bash_profile on macOS
134
+ bashrc = File.join(home, ".bashrc")
135
+ bash_profile = File.join(home, ".bash_profile")
136
+ File.exist?(bashrc) ? bashrc : bash_profile
137
+ end
138
+ end
139
+
140
+ def shell_integration_installed?(rc_file)
141
+ return false unless File.exist?(rc_file)
142
+
143
+ content = File.read(rc_file)
144
+ content.include?(SHELL_MARKER)
145
+ end
146
+
147
+ def install_shell_integration(rc_file, _shell_type)
148
+ if shell_integration_installed?(rc_file)
149
+ @ui.info("Shell integration already installed in #{rc_file}")
150
+ @ui.info("Use --uninstall to remove it first if you want to reinstall")
151
+ return
152
+ end
153
+
154
+ # Ensure rc file exists
155
+ FileUtils.touch(rc_file) unless File.exist?(rc_file)
156
+
157
+ # Append shell function
158
+ File.open(rc_file, "a") do |f|
159
+ f.puts "" # Add blank line before
160
+ f.puts SHELL_FUNCTION
161
+ end
162
+
163
+ @ui.success("Installed shell integration to #{rc_file}")
164
+ @ui.newline
165
+ @ui.info("The following functions were added:")
166
+ @ui.newline
167
+ puts " sxn-enter - Navigate to current session directory"
168
+ puts " sxn-up - Navigate to project root from session"
169
+ @ui.newline
170
+ @ui.recovery_suggestion("Run 'source #{rc_file}' or restart your shell to use them")
171
+ end
172
+
173
+ def uninstall_shell_integration(rc_file, _shell_type)
174
+ unless File.exist?(rc_file)
175
+ @ui.info("Shell configuration file not found: #{rc_file}")
176
+ return
177
+ end
178
+
179
+ unless shell_integration_installed?(rc_file)
180
+ @ui.info("Shell integration not installed in #{rc_file}")
181
+ return
182
+ end
183
+
184
+ # Read file and remove sxn block
185
+ content = File.read(rc_file)
186
+ # Remove the block between markers (including blank line before)
187
+ pattern = /\n?#{Regexp.escape(SHELL_MARKER)}.*?#{Regexp.escape(SHELL_MARKER_END)}\n?/m
188
+ new_content = content.gsub(pattern, "\n")
189
+
190
+ File.write(rc_file, new_content)
191
+
192
+ @ui.success("Removed shell integration from #{rc_file}")
193
+ @ui.recovery_suggestion("Run 'source #{rc_file}' or restart your shell")
194
+ end
195
+
60
196
  def determine_sessions_folder(folder)
61
197
  return folder if folder && !options[:quiet]
62
198
 
@@ -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
@@ -41,6 +41,7 @@ module Sxn
41
41
  option :session, type: :string, aliases: "-s", desc: "Target session (defaults to current)"
42
42
  option :apply_rules, type: :boolean, default: true, desc: "Apply project rules after creation"
43
43
  option :interactive, type: :boolean, aliases: "-i", desc: "Interactive mode"
44
+ option :verbose, type: :boolean, aliases: "-v", desc: "Show detailed output for debugging"
44
45
 
45
46
  def add(project_name = nil, branch = nil)
46
47
  ensure_initialized!
@@ -66,12 +67,16 @@ module Sxn
66
67
  end
67
68
 
68
69
  begin
70
+ # Enable debug mode if verbose flag is set
71
+ ENV["SXN_DEBUG"] = "true" if options[:verbose]
72
+
69
73
  @ui.progress_start("Creating worktree for #{project_name}")
70
74
 
71
75
  worktree = @worktree_manager.add_worktree(
72
76
  project_name,
73
77
  branch,
74
- session_name: session_name
78
+ session_name: session_name,
79
+ verbose: options[:verbose]
75
80
  )
76
81
 
77
82
  @ui.progress_done
@@ -84,7 +89,18 @@ module Sxn
84
89
  rescue Sxn::Error => e
85
90
  @ui.progress_failed
86
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
+
87
101
  exit(e.exit_code)
102
+ ensure
103
+ ENV.delete("SXN_DEBUG") if options[:verbose]
88
104
  end
89
105
  end
90
106
 
@@ -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,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"] || {}
@@ -14,7 +14,7 @@ module Sxn
14
14
  @project_manager = ProjectManager.new(@config_manager)
15
15
  end
16
16
 
17
- def add_worktree(project_name, branch = nil, session_name: nil)
17
+ def add_worktree(project_name, branch = nil, session_name: nil, verbose: false)
18
18
  # Use current session if not specified
19
19
  session_name ||= @config_manager.current_session
20
20
  raise Sxn::NoActiveSessionError, "No active session. Use 'sxn use <session>' first." unless session_name
@@ -26,10 +26,12 @@ module Sxn
26
26
  raise Sxn::ProjectNotFoundError, "Project '#{project_name}' not found" unless project
27
27
 
28
28
  # Determine branch name
29
- # If no branch specified, use session name as the branch name
29
+ # If no branch specified, use session's default branch from .sxnrc, then fallback to session name
30
30
  # If branch starts with "remote:", handle remote branch tracking
31
31
  if branch.nil?
32
- branch = session_name
32
+ session_config = SessionConfig.new(session[:path])
33
+ branch = session_config.default_branch if session_config.exists?
34
+ branch ||= session_name
33
35
  elsif branch.start_with?("remote:")
34
36
  remote_branch = branch.sub("remote:", "")
35
37
  # Fetch the remote branch first
@@ -67,7 +69,7 @@ module Sxn
67
69
  handle_orphaned_worktree(project[:path], worktree_path)
68
70
 
69
71
  # Create the worktree
70
- create_git_worktree(project[:path], worktree_path, branch)
72
+ create_git_worktree(project[:path], worktree_path, branch, verbose: verbose)
71
73
 
72
74
  # Register worktree with session
73
75
  @session_manager.add_worktree_to_session(session_name, project_name, worktree_path, branch)
@@ -81,6 +83,11 @@ module Sxn
81
83
  rescue StandardError => e
82
84
  # Clean up on failure
83
85
  FileUtils.rm_rf(worktree_path)
86
+
87
+ # If it's already our error with details, re-raise it
88
+ raise e if e.is_a?(Sxn::WorktreeCreationError)
89
+
90
+ # Otherwise wrap it
84
91
  raise Sxn::WorktreeCreationError, "Failed to create worktree: #{e.message}"
85
92
  end
86
93
  end
@@ -237,7 +244,7 @@ module Sxn
237
244
  end
238
245
  end
239
246
 
240
- def create_git_worktree(project_path, worktree_path, branch)
247
+ def create_git_worktree(project_path, worktree_path, branch, verbose: false)
241
248
  Dir.chdir(project_path) do
242
249
  # Check if branch exists
243
250
  branch_exists = system("git show-ref --verify --quiet refs/heads/#{Shellwords.escape(branch)}",
@@ -274,15 +281,35 @@ module Sxn
274
281
  error_msg = error_msg.lines.grep(/fatal:/).first&.strip || error_msg
275
282
  end
276
283
 
277
- if ENV["SXN_DEBUG"]
284
+ details = []
285
+ details << "Command: #{cmd.join(" ")}"
286
+ details << "Working directory: #{project_path}"
287
+ details << "Target path: #{worktree_path}"
288
+ details << "Branch: #{branch}"
289
+ details << ""
290
+ details << "Git output:"
291
+ details << "STDOUT: #{stdout.strip.empty? ? "(empty)" : stdout}"
292
+ details << "STDERR: #{stderr.strip.empty? ? "(empty)" : stderr}"
293
+ details << "Exit status: #{status.exitstatus}"
294
+
295
+ # Check for common issues
296
+ if !File.directory?(project_path)
297
+ details << "\n⚠️ Project directory does not exist: #{project_path}"
298
+ elsif !File.directory?(File.join(project_path, ".git"))
299
+ details << "\n⚠️ Not a git repository: #{project_path}"
300
+ details << " This might be a git submodule. Try:"
301
+ details << " 1. Ensure the project path points to the submodule directory"
302
+ details << " 2. Check if 'git submodule update --init' has been run"
303
+ end
304
+
305
+ details << "\n⚠️ Target path already exists: #{worktree_path}" if File.exist?(worktree_path)
306
+
307
+ if ENV["SXN_DEBUG"] || verbose
278
308
  puts "[DEBUG] Git worktree command failed:"
279
- puts " Command: #{cmd.join(" ")}"
280
- puts " Directory: #{project_path}"
281
- puts " STDOUT: #{stdout}"
282
- puts " STDERR: #{stderr}"
309
+ details.each { |line| puts " #{line}" }
283
310
  end
284
311
 
285
- raise error_msg
312
+ raise Sxn::WorktreeCreationError.new(error_msg, details: details.join("\n"))
286
313
  end
287
314
  end
288
315
  end
data/lib/sxn/core.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "core/config_manager"
4
+ require_relative "core/session_config"
4
5
  require_relative "core/session_manager"
5
6
  require_relative "core/project_manager"
6
7
  require_relative "core/worktree_manager"
data/lib/sxn/errors.rb CHANGED
@@ -37,7 +37,16 @@ module Sxn
37
37
  class WorktreeError < GitError; end
38
38
  class WorktreeExistsError < WorktreeError; end
39
39
  class WorktreeNotFoundError < WorktreeError; end
40
- class WorktreeCreationError < WorktreeError; end
40
+
41
+ class WorktreeCreationError < WorktreeError
42
+ attr_reader :details
43
+
44
+ def initialize(message, details: nil)
45
+ super(message)
46
+ @details = details
47
+ end
48
+ end
49
+
41
50
  class WorktreeRemovalError < WorktreeError; end
42
51
  class BranchError < GitError; end
43
52
 
data/lib/sxn/ui/prompt.rb CHANGED
@@ -68,6 +68,10 @@ module Sxn
68
68
  end
69
69
  end
70
70
 
71
+ def default_branch(session_name:)
72
+ branch_name("Default branch for worktrees:", default: session_name)
73
+ end
74
+
71
75
  def confirm_deletion(item_name, item_type = "item")
72
76
  ask_yes_no("Are you sure you want to delete #{item_type} '#{item_name}'? This action cannot be undone.",
73
77
  default: false)
data/lib/sxn/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Sxn
4
- VERSION = "0.2.3"
4
+ VERSION = "0.2.5"
5
5
  end
data/sxn.gemspec ADDED
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/sxn/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "sxn"
7
+ spec.version = Sxn::VERSION
8
+ spec.authors = ["Ernest Sim"]
9
+ spec.email = ["ernest.codes@gmail.com"]
10
+
11
+ spec.summary = "Session management for multi-repository development"
12
+ spec.description = "Sxn simplifies git worktree management with intelligent project rules and secure automation"
13
+ spec.homepage = "https://github.com/idl3/sxn"
14
+ spec.license = "MIT"
15
+ spec.required_ruby_version = ">= 3.2.0"
16
+
17
+ spec.metadata["allowed_push_host"] = "https://rubygems.org"
18
+ spec.metadata["homepage_uri"] = spec.homepage
19
+ spec.metadata["source_code_uri"] = spec.homepage
20
+ spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
21
+ spec.metadata["rubygems_mfa_required"] = "true"
22
+
23
+ # Specify which files should be added to the gem when it is released.
24
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
25
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
26
+ `git ls-files -z`.split("\x0").reject do |f|
27
+ (f == __FILE__) ||
28
+ f.match(%r{\A(?:(?:test|spec|features)/|\.(?:git|travis|circleci)|appveyor)}) ||
29
+ f.match(/\.db-(?:shm|wal)\z/) || # Exclude SQLite temp files
30
+ f.match(/\.gem\z/) # Exclude gem files
31
+ end
32
+ end
33
+
34
+ spec.bindir = "bin"
35
+ spec.executables = spec.files.grep(%r{\Abin/}) { |f| File.basename(f) }
36
+ spec.require_paths = ["lib"]
37
+
38
+ # Core CLI dependencies
39
+ spec.add_dependency "pastel", "~> 0.8" # Terminal colors
40
+ spec.add_dependency "thor", "~> 1.3" # CLI framework
41
+ spec.add_dependency "tty-progressbar", "~> 0.18" # Progress bars
42
+ spec.add_dependency "tty-prompt", "~> 0.23" # Interactive prompts
43
+ spec.add_dependency "tty-table", "~> 0.12" # Table formatting
44
+
45
+ # Configuration and data management
46
+ spec.add_dependency "dry-configurable", "~> 1.0" # Configuration management
47
+ spec.add_dependency "sqlite3", "~> 1.6" # Session database
48
+ spec.add_dependency "zeitwerk", "~> 2.6" # Code loading
49
+
50
+ # Template engine (secure, sandboxed)
51
+ spec.add_dependency "liquid", "~> 5.4" # Safe template processing
52
+
53
+ # MCP server dependencies
54
+ spec.add_dependency "async", "~> 2.0" # Async operations
55
+ spec.add_dependency "json-schema", "~> 4.0" # Schema validation
56
+
57
+ # Security and encryption
58
+ spec.add_dependency "bcrypt", "~> 3.1" # Password hashing
59
+ spec.add_dependency "openssl", ">= 3.0" # Encryption support
60
+ spec.add_dependency "ostruct" # OpenStruct for Ruby 3.5+ compatibility
61
+
62
+ # File system operations
63
+ spec.add_dependency "listen", "~> 3.8" # File watching for config cache
64
+ spec.add_dependency "parallel", "~> 1.23" # Parallel execution
65
+
66
+ # Development dependencies
67
+ spec.add_development_dependency "aruba", "~> 2.1" # CLI testing
68
+ spec.add_development_dependency "benchmark" # Benchmark for Ruby 3.5+ compatibility
69
+ spec.add_development_dependency "benchmark-ips", "~> 2.12" # Performance benchmarking
70
+ spec.add_development_dependency "bundler", "~> 2.4"
71
+ spec.add_development_dependency "climate_control", "~> 1.2" # Environment variable testing
72
+ spec.add_development_dependency "faker", "~> 3.2" # Test data generation
73
+ spec.add_development_dependency "memory_profiler", "~> 1.0" # Memory profiling
74
+ spec.add_development_dependency "rake", "~> 13.0"
75
+ spec.add_development_dependency "rspec", "~> 3.12"
76
+ spec.add_development_dependency "rubocop", "~> 1.50" # Code linting
77
+ spec.add_development_dependency "rubocop-performance", "~> 1.16"
78
+ spec.add_development_dependency "rubocop-rspec", "~> 2.19"
79
+ spec.add_development_dependency "simplecov", "~> 0.22" # Code coverage
80
+ spec.add_development_dependency "vcr", "~> 6.2" # HTTP interaction recording
81
+ spec.add_development_dependency "webmock", "~> 3.19" # HTTP mocking for MCP tests
82
+
83
+ # For more information and examples about making a new gem, check out our
84
+ # guide at: https://bundler.io/guides/creating_gem.html
85
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sxn
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.3
4
+ version: 0.2.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ernest Sim
@@ -518,6 +518,7 @@ files:
518
518
  - lib/sxn/core/config_manager.rb
519
519
  - lib/sxn/core/project_manager.rb
520
520
  - lib/sxn/core/rules_manager.rb
521
+ - lib/sxn/core/session_config.rb
521
522
  - lib/sxn/core/session_manager.rb
522
523
  - lib/sxn/core/worktree_manager.rb
523
524
  - lib/sxn/database.rb
@@ -610,6 +611,7 @@ files:
610
611
  - sig/sxn/ui/prompt.rbs
611
612
  - sig/sxn/ui/table.rbs
612
613
  - sig/sxn/version.rbs
614
+ - sxn.gemspec
613
615
  homepage: https://github.com/idl3/sxn
614
616
  licenses:
615
617
  - MIT