sxn 0.2.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.
Files changed (156) hide show
  1. checksums.yaml +7 -0
  2. data/.gem_rbs_collection/addressable/2.8/.rbs_meta.yaml +9 -0
  3. data/.gem_rbs_collection/addressable/2.8/addressable.rbs +62 -0
  4. data/.gem_rbs_collection/async/2.12/.rbs_meta.yaml +9 -0
  5. data/.gem_rbs_collection/async/2.12/async.rbs +119 -0
  6. data/.gem_rbs_collection/async/2.12/kernel.rbs +5 -0
  7. data/.gem_rbs_collection/async/2.12/manifest.yaml +7 -0
  8. data/.gem_rbs_collection/bcrypt/3.1/.rbs_meta.yaml +9 -0
  9. data/.gem_rbs_collection/bcrypt/3.1/bcrypt.rbs +47 -0
  10. data/.gem_rbs_collection/bcrypt/3.1/manifest.yaml +2 -0
  11. data/.gem_rbs_collection/bigdecimal/3.1/.rbs_meta.yaml +9 -0
  12. data/.gem_rbs_collection/bigdecimal/3.1/bigdecimal-math.rbs +119 -0
  13. data/.gem_rbs_collection/bigdecimal/3.1/bigdecimal.rbs +1630 -0
  14. data/.gem_rbs_collection/concurrent-ruby/1.1/.rbs_meta.yaml +9 -0
  15. data/.gem_rbs_collection/concurrent-ruby/1.1/array.rbs +4 -0
  16. data/.gem_rbs_collection/concurrent-ruby/1.1/executor.rbs +26 -0
  17. data/.gem_rbs_collection/concurrent-ruby/1.1/hash.rbs +4 -0
  18. data/.gem_rbs_collection/concurrent-ruby/1.1/map.rbs +65 -0
  19. data/.gem_rbs_collection/concurrent-ruby/1.1/promises.rbs +249 -0
  20. data/.gem_rbs_collection/concurrent-ruby/1.1/utility/processor_counter.rbs +5 -0
  21. data/.gem_rbs_collection/diff-lcs/1.5/.rbs_meta.yaml +9 -0
  22. data/.gem_rbs_collection/diff-lcs/1.5/diff-lcs.rbs +11 -0
  23. data/.gem_rbs_collection/listen/3.9/.rbs_meta.yaml +9 -0
  24. data/.gem_rbs_collection/listen/3.9/listen.rbs +25 -0
  25. data/.gem_rbs_collection/listen/3.9/listener.rbs +24 -0
  26. data/.gem_rbs_collection/mini_mime/0.1/.rbs_meta.yaml +9 -0
  27. data/.gem_rbs_collection/mini_mime/0.1/mini_mime.rbs +14 -0
  28. data/.gem_rbs_collection/parallel/1.20/.rbs_meta.yaml +9 -0
  29. data/.gem_rbs_collection/parallel/1.20/parallel.rbs +86 -0
  30. data/.gem_rbs_collection/rake/13.0/.rbs_meta.yaml +9 -0
  31. data/.gem_rbs_collection/rake/13.0/manifest.yaml +2 -0
  32. data/.gem_rbs_collection/rake/13.0/rake.rbs +39 -0
  33. data/.gem_rbs_collection/rubocop-ast/1.46/.rbs_meta.yaml +9 -0
  34. data/.gem_rbs_collection/rubocop-ast/1.46/rubocop-ast.rbs +822 -0
  35. data/.gem_rbs_collection/sqlite3/2.0/.rbs_meta.yaml +9 -0
  36. data/.gem_rbs_collection/sqlite3/2.0/database.rbs +20 -0
  37. data/.gem_rbs_collection/sqlite3/2.0/pragmas.rbs +5 -0
  38. data/.rspec +4 -0
  39. data/.rubocop.yml +121 -0
  40. data/.simplecov +51 -0
  41. data/CHANGELOG.md +49 -0
  42. data/Gemfile +24 -0
  43. data/Gemfile.lock +329 -0
  44. data/LICENSE.txt +21 -0
  45. data/README.md +225 -0
  46. data/Rakefile +54 -0
  47. data/Steepfile +50 -0
  48. data/bin/sxn +6 -0
  49. data/lib/sxn/CLI.rb +275 -0
  50. data/lib/sxn/commands/init.rb +137 -0
  51. data/lib/sxn/commands/projects.rb +350 -0
  52. data/lib/sxn/commands/rules.rb +435 -0
  53. data/lib/sxn/commands/sessions.rb +300 -0
  54. data/lib/sxn/commands/worktrees.rb +416 -0
  55. data/lib/sxn/commands.rb +13 -0
  56. data/lib/sxn/config/config_cache.rb +295 -0
  57. data/lib/sxn/config/config_discovery.rb +242 -0
  58. data/lib/sxn/config/config_validator.rb +562 -0
  59. data/lib/sxn/config.rb +259 -0
  60. data/lib/sxn/core/config_manager.rb +290 -0
  61. data/lib/sxn/core/project_manager.rb +307 -0
  62. data/lib/sxn/core/rules_manager.rb +306 -0
  63. data/lib/sxn/core/session_manager.rb +336 -0
  64. data/lib/sxn/core/worktree_manager.rb +281 -0
  65. data/lib/sxn/core.rb +13 -0
  66. data/lib/sxn/database/errors.rb +29 -0
  67. data/lib/sxn/database/session_database.rb +691 -0
  68. data/lib/sxn/database.rb +24 -0
  69. data/lib/sxn/errors.rb +76 -0
  70. data/lib/sxn/rules/base_rule.rb +367 -0
  71. data/lib/sxn/rules/copy_files_rule.rb +346 -0
  72. data/lib/sxn/rules/errors.rb +28 -0
  73. data/lib/sxn/rules/project_detector.rb +871 -0
  74. data/lib/sxn/rules/rules_engine.rb +485 -0
  75. data/lib/sxn/rules/setup_commands_rule.rb +307 -0
  76. data/lib/sxn/rules/template_rule.rb +262 -0
  77. data/lib/sxn/rules.rb +148 -0
  78. data/lib/sxn/runtime_validations.rb +96 -0
  79. data/lib/sxn/security/secure_command_executor.rb +364 -0
  80. data/lib/sxn/security/secure_file_copier.rb +478 -0
  81. data/lib/sxn/security/secure_path_validator.rb +258 -0
  82. data/lib/sxn/security.rb +15 -0
  83. data/lib/sxn/templates/common/gitignore.liquid +99 -0
  84. data/lib/sxn/templates/common/session-info.md.liquid +58 -0
  85. data/lib/sxn/templates/errors.rb +36 -0
  86. data/lib/sxn/templates/javascript/README.md.liquid +59 -0
  87. data/lib/sxn/templates/javascript/session-info.md.liquid +206 -0
  88. data/lib/sxn/templates/rails/CLAUDE.md.liquid +78 -0
  89. data/lib/sxn/templates/rails/database.yml.liquid +31 -0
  90. data/lib/sxn/templates/rails/session-info.md.liquid +144 -0
  91. data/lib/sxn/templates/template_engine.rb +346 -0
  92. data/lib/sxn/templates/template_processor.rb +279 -0
  93. data/lib/sxn/templates/template_security.rb +410 -0
  94. data/lib/sxn/templates/template_variables.rb +713 -0
  95. data/lib/sxn/templates.rb +28 -0
  96. data/lib/sxn/ui/output.rb +103 -0
  97. data/lib/sxn/ui/progress_bar.rb +91 -0
  98. data/lib/sxn/ui/prompt.rb +116 -0
  99. data/lib/sxn/ui/table.rb +183 -0
  100. data/lib/sxn/ui.rb +12 -0
  101. data/lib/sxn/version.rb +5 -0
  102. data/lib/sxn.rb +63 -0
  103. data/rbs_collection.lock.yaml +180 -0
  104. data/rbs_collection.yaml +39 -0
  105. data/scripts/test.sh +31 -0
  106. data/sig/external/liquid.rbs +116 -0
  107. data/sig/external/thor.rbs +99 -0
  108. data/sig/external/tty.rbs +71 -0
  109. data/sig/sxn/cli.rbs +46 -0
  110. data/sig/sxn/commands/init.rbs +38 -0
  111. data/sig/sxn/commands/projects.rbs +72 -0
  112. data/sig/sxn/commands/rules.rbs +95 -0
  113. data/sig/sxn/commands/sessions.rbs +62 -0
  114. data/sig/sxn/commands/worktrees.rbs +82 -0
  115. data/sig/sxn/commands.rbs +6 -0
  116. data/sig/sxn/config/config_cache.rbs +67 -0
  117. data/sig/sxn/config/config_discovery.rbs +64 -0
  118. data/sig/sxn/config/config_validator.rbs +64 -0
  119. data/sig/sxn/config.rbs +74 -0
  120. data/sig/sxn/core/config_manager.rbs +67 -0
  121. data/sig/sxn/core/project_manager.rbs +52 -0
  122. data/sig/sxn/core/rules_manager.rbs +54 -0
  123. data/sig/sxn/core/session_manager.rbs +59 -0
  124. data/sig/sxn/core/worktree_manager.rbs +50 -0
  125. data/sig/sxn/core.rbs +87 -0
  126. data/sig/sxn/database/errors.rbs +37 -0
  127. data/sig/sxn/database/session_database.rbs +151 -0
  128. data/sig/sxn/database.rbs +83 -0
  129. data/sig/sxn/errors.rbs +89 -0
  130. data/sig/sxn/rules/base_rule.rbs +137 -0
  131. data/sig/sxn/rules/copy_files_rule.rbs +65 -0
  132. data/sig/sxn/rules/errors.rbs +33 -0
  133. data/sig/sxn/rules/project_detector.rbs +115 -0
  134. data/sig/sxn/rules/rules_engine.rbs +118 -0
  135. data/sig/sxn/rules/setup_commands_rule.rbs +60 -0
  136. data/sig/sxn/rules/template_rule.rbs +44 -0
  137. data/sig/sxn/rules.rbs +287 -0
  138. data/sig/sxn/runtime_validations.rbs +16 -0
  139. data/sig/sxn/security/secure_command_executor.rbs +63 -0
  140. data/sig/sxn/security/secure_file_copier.rbs +79 -0
  141. data/sig/sxn/security/secure_path_validator.rbs +30 -0
  142. data/sig/sxn/security.rbs +128 -0
  143. data/sig/sxn/templates/errors.rbs +43 -0
  144. data/sig/sxn/templates/template_engine.rbs +50 -0
  145. data/sig/sxn/templates/template_processor.rbs +44 -0
  146. data/sig/sxn/templates/template_security.rbs +62 -0
  147. data/sig/sxn/templates/template_variables.rbs +103 -0
  148. data/sig/sxn/templates.rbs +104 -0
  149. data/sig/sxn/ui/output.rbs +50 -0
  150. data/sig/sxn/ui/progress_bar.rbs +39 -0
  151. data/sig/sxn/ui/prompt.rbs +38 -0
  152. data/sig/sxn/ui/table.rbs +43 -0
  153. data/sig/sxn/ui.rbs +63 -0
  154. data/sig/sxn/version.rbs +5 -0
  155. data/sig/sxn.rbs +29 -0
  156. metadata +635 -0
@@ -0,0 +1,300 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+
5
+ module Sxn
6
+ module Commands
7
+ # Manage development sessions
8
+ class Sessions < Thor
9
+ include Thor::Actions
10
+
11
+ def initialize(args = ARGV, local_options = {}, config = {})
12
+ super
13
+ @ui = Sxn::UI::Output.new
14
+ @prompt = Sxn::UI::Prompt.new
15
+ @table = Sxn::UI::Table.new
16
+ @config_manager = Sxn::Core::ConfigManager.new
17
+ @session_manager = Sxn::Core::SessionManager.new(@config_manager)
18
+ end
19
+
20
+ desc "add NAME", "Create a new session"
21
+ option :description, type: :string, aliases: "-d", desc: "Session description"
22
+ option :linear_task, type: :string, aliases: "-l", desc: "Linear task ID"
23
+ option :activate, type: :boolean, default: true, desc: "Activate session after creation"
24
+
25
+ def add(name = nil)
26
+ ensure_initialized!
27
+
28
+ # Get session name interactively if not provided
29
+ if name.nil?
30
+ existing_sessions = @session_manager.list_sessions.map { |s| s[:name] }
31
+ name = @prompt.session_name(existing_sessions: existing_sessions)
32
+ end
33
+
34
+ begin
35
+ @ui.progress_start("Creating session '#{name}'")
36
+
37
+ session = @session_manager.create_session(
38
+ name,
39
+ description: options[:description],
40
+ linear_task: options[:linear_task]
41
+ )
42
+
43
+ @ui.progress_done
44
+ @ui.success("Created session '#{name}'")
45
+
46
+ if options[:activate]
47
+ @session_manager.use_session(name)
48
+ @ui.success("Activated session '#{name}'")
49
+ end
50
+
51
+ display_session_info(session)
52
+ rescue Sxn::Error => e
53
+ @ui.progress_failed
54
+ @ui.error(e.message)
55
+ exit(e.exit_code)
56
+ rescue StandardError => e
57
+ @ui.progress_failed
58
+ @ui.error("Unexpected error: #{e.message}")
59
+ @ui.debug(e.backtrace.join("\n")) if ENV["SXN_DEBUG"]
60
+ exit(1)
61
+ end
62
+ end
63
+
64
+ desc "remove NAME", "Remove a session"
65
+ option :force, type: :boolean, aliases: "-f", desc: "Force removal even with uncommitted changes"
66
+
67
+ def remove(name = nil)
68
+ ensure_initialized!
69
+
70
+ # Interactive selection if name not provided
71
+ if name.nil?
72
+ sessions = @session_manager.list_sessions
73
+ if sessions.empty?
74
+ @ui.empty_state("No sessions found")
75
+ return
76
+ end
77
+
78
+ choices = sessions.map { |s| { name: s[:name], value: s[:name] } }
79
+ name = @prompt.select("Select session to remove:", choices)
80
+ end
81
+
82
+ unless @prompt.confirm_deletion(name, "session")
83
+ @ui.info("Cancelled")
84
+ return
85
+ end
86
+
87
+ begin
88
+ @ui.progress_start("Removing session '#{name}'")
89
+ @session_manager.remove_session(name, force: options[:force])
90
+ @ui.progress_done
91
+ @ui.success("Removed session '#{name}'")
92
+ rescue Sxn::SessionHasChangesError => e
93
+ @ui.progress_failed
94
+ @ui.error(e.message)
95
+ @ui.recovery_suggestion("Use --force to remove anyway, or commit/stash changes first")
96
+ exit(e.exit_code)
97
+ rescue Sxn::Error => e
98
+ @ui.progress_failed
99
+ @ui.error(e.message)
100
+ exit(e.exit_code)
101
+ end
102
+ end
103
+
104
+ desc "list", "List all sessions"
105
+ option :status, type: :string, enum: %w[active inactive archived], desc: "Filter by status"
106
+ option :limit, type: :numeric, default: 50, desc: "Maximum number of sessions to show"
107
+
108
+ def list
109
+ ensure_initialized!
110
+
111
+ begin
112
+ sessions = @session_manager.list_sessions(
113
+ status: options[:status],
114
+ limit: options[:limit]
115
+ )
116
+
117
+ @ui.section("Sessions")
118
+
119
+ if sessions.empty?
120
+ @ui.empty_state("No sessions found")
121
+ suggest_create_session
122
+ else
123
+ @table.sessions(sessions)
124
+ @ui.newline
125
+ @ui.info("Total: #{sessions.size} sessions")
126
+
127
+ current = @session_manager.current_session
128
+ if current
129
+ @ui.info("Current: #{current[:name]}")
130
+ else
131
+ @ui.recovery_suggestion("Use 'sxn use <session>' to activate a session")
132
+ end
133
+ end
134
+ rescue Sxn::Error => e
135
+ @ui.error(e.message)
136
+ exit(e.exit_code)
137
+ end
138
+ end
139
+
140
+ desc "use NAME", "Switch to a session"
141
+ def use(name = nil)
142
+ ensure_initialized!
143
+
144
+ # Interactive selection if name not provided
145
+ if name.nil?
146
+ sessions = @session_manager.list_sessions(status: "active")
147
+ if sessions.empty?
148
+ @ui.empty_state("No active sessions found")
149
+ suggest_create_session
150
+ return
151
+ end
152
+
153
+ choices = sessions.map do |s|
154
+ { name: "#{s[:name]} - #{s[:description] || "No description"}", value: s[:name] }
155
+ end
156
+ name = @prompt.select("Select session to activate:", choices)
157
+ end
158
+
159
+ begin
160
+ session = @session_manager.use_session(name)
161
+ @ui.success("Activated session '#{name}'")
162
+ display_session_info(session)
163
+ rescue Sxn::Error => e
164
+ @ui.error(e.message)
165
+ exit(e.exit_code)
166
+ end
167
+ end
168
+
169
+ desc "current", "Show current session"
170
+ option :verbose, type: :boolean, aliases: "-v", desc: "Show detailed information"
171
+
172
+ def current
173
+ ensure_initialized!
174
+
175
+ begin
176
+ session = @session_manager.current_session
177
+
178
+ if session.nil?
179
+ @ui.info("No active session")
180
+ suggest_create_session
181
+ return
182
+ end
183
+
184
+ @ui.section("Current Session")
185
+ display_session_info(session, verbose: options[:verbose])
186
+ rescue Sxn::Error => e
187
+ @ui.error(e.message)
188
+ exit(e.exit_code)
189
+ end
190
+ end
191
+
192
+ desc "archive NAME", "Archive a session"
193
+ def archive(name = nil)
194
+ ensure_initialized!
195
+
196
+ if name.nil?
197
+ active_sessions = @session_manager.list_sessions(status: "active")
198
+ if active_sessions.empty?
199
+ @ui.empty_state("No active sessions to archive")
200
+ return
201
+ end
202
+
203
+ choices = active_sessions.map { |s| { name: s[:name], value: s[:name] } }
204
+ name = @prompt.select("Select session to archive:", choices)
205
+ end
206
+
207
+ begin
208
+ @session_manager.archive_session(name)
209
+ @ui.success("Archived session '#{name}'")
210
+ rescue Sxn::Error => e
211
+ @ui.error(e.message)
212
+ exit(e.exit_code)
213
+ end
214
+ end
215
+
216
+ desc "activate NAME", "Activate an archived session"
217
+ def activate(name = nil)
218
+ ensure_initialized!
219
+
220
+ if name.nil?
221
+ archived_sessions = @session_manager.list_sessions(status: "archived")
222
+ if archived_sessions.empty?
223
+ @ui.empty_state("No archived sessions to activate")
224
+ return
225
+ end
226
+
227
+ choices = archived_sessions.map { |s| { name: s[:name], value: s[:name] } }
228
+ name = @prompt.select("Select session to activate:", choices)
229
+ end
230
+
231
+ begin
232
+ @session_manager.activate_session(name)
233
+ @ui.success("Activated session '#{name}'")
234
+ rescue Sxn::Error => e
235
+ @ui.error(e.message)
236
+ exit(e.exit_code)
237
+ end
238
+ end
239
+
240
+ private
241
+
242
+ def ensure_initialized!
243
+ return if @config_manager.initialized?
244
+
245
+ @ui.error("Project not initialized")
246
+ @ui.recovery_suggestion("Run 'sxn init' to initialize sxn in this project")
247
+ exit(1)
248
+ end
249
+
250
+ def display_session_info(session, verbose: false)
251
+ return unless session
252
+
253
+ @ui.newline
254
+ @ui.key_value("Name", session[:name] || "Unknown")
255
+ @ui.key_value("Status", (session[:status] || "unknown").capitalize)
256
+ @ui.key_value("Path", session[:path] || "Unknown")
257
+
258
+ @ui.key_value("Description", session[:description]) if session[:description]
259
+
260
+ @ui.key_value("Linear Task", session[:linear_task]) if session[:linear_task]
261
+
262
+ @ui.key_value("Created", session[:created_at] || "Unknown")
263
+ @ui.key_value("Updated", session[:updated_at] || "Unknown")
264
+
265
+ if verbose && session[:projects]&.any?
266
+ @ui.newline
267
+ @ui.subsection("Projects")
268
+ session[:projects].each { |project| @ui.list_item(project) }
269
+ end
270
+
271
+ @ui.newline
272
+ display_session_commands(session[:name]) if session[:name]
273
+ end
274
+
275
+ def display_session_commands(session_name)
276
+ @ui.subsection("Available Commands")
277
+
278
+ @ui.command_example(
279
+ "sxn worktree add <project> [branch]",
280
+ "Add a worktree to this session"
281
+ )
282
+
283
+ @ui.command_example(
284
+ "sxn worktree list",
285
+ "List worktrees in this session"
286
+ )
287
+
288
+ @ui.command_example(
289
+ "cd #{@session_manager.get_session(session_name)[:path]}",
290
+ "Navigate to session directory"
291
+ )
292
+ end
293
+
294
+ def suggest_create_session
295
+ @ui.newline
296
+ @ui.recovery_suggestion("Create your first session with 'sxn add <session-name>'")
297
+ end
298
+ end
299
+ end
300
+ end
@@ -0,0 +1,416 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+
5
+ module Sxn
6
+ module Commands
7
+ # Manage git worktrees
8
+ class Worktrees < Thor
9
+ include Thor::Actions
10
+
11
+ def initialize(args = ARGV, local_options = {}, config = {})
12
+ super
13
+ @ui = Sxn::UI::Output.new
14
+ @prompt = Sxn::UI::Prompt.new
15
+ @table = Sxn::UI::Table.new
16
+ @config_manager = Sxn::Core::ConfigManager.new
17
+ @project_manager = Sxn::Core::ProjectManager.new(@config_manager)
18
+ @session_manager = Sxn::Core::SessionManager.new(@config_manager)
19
+ @worktree_manager = Sxn::Core::WorktreeManager.new(@config_manager, @session_manager)
20
+ end
21
+
22
+ desc "add PROJECT [BRANCH]", "Add worktree to current session"
23
+ option :session, type: :string, aliases: "-s", desc: "Target session (defaults to current)"
24
+ option :apply_rules, type: :boolean, default: true, desc: "Apply project rules after creation"
25
+ option :interactive, type: :boolean, aliases: "-i", desc: "Interactive mode"
26
+
27
+ def add(project_name = nil, branch = nil)
28
+ ensure_initialized!
29
+
30
+ # Interactive selection if project not provided
31
+ if options[:interactive] || project_name.nil?
32
+ project_name = select_project("Select project for worktree:")
33
+ return if project_name.nil?
34
+ end
35
+
36
+ # Interactive branch selection if not provided
37
+ 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])
40
+ end
41
+
42
+ session_name = options[:session] || @config_manager.current_session
43
+ unless session_name
44
+ @ui.error("No active session")
45
+ @ui.recovery_suggestion("Use 'sxn use <session>' or specify --session")
46
+ exit(1)
47
+ end
48
+
49
+ begin
50
+ @ui.progress_start("Creating worktree for #{project_name}")
51
+
52
+ worktree = @worktree_manager.add_worktree(
53
+ project_name,
54
+ branch,
55
+ session_name: session_name
56
+ )
57
+
58
+ @ui.progress_done
59
+ @ui.success("Created worktree for #{project_name}")
60
+
61
+ display_worktree_info(worktree)
62
+
63
+ # Apply rules if requested
64
+ apply_project_rules(project_name, session_name) if options[:apply_rules]
65
+ rescue Sxn::Error => e
66
+ @ui.progress_failed
67
+ @ui.error(e.message)
68
+ exit(e.exit_code)
69
+ end
70
+ end
71
+
72
+ desc "remove PROJECT", "Remove worktree from current session"
73
+ option :session, type: :string, aliases: "-s", desc: "Target session (defaults to current)"
74
+ option :force, type: :boolean, aliases: "-f", desc: "Force removal even with uncommitted changes"
75
+
76
+ def remove(project_name = nil)
77
+ ensure_initialized!
78
+
79
+ session_name = options[:session] || @config_manager.current_session
80
+ unless session_name
81
+ @ui.error("No active session")
82
+ @ui.recovery_suggestion("Use 'sxn use <session>' or specify --session")
83
+ exit(1)
84
+ end
85
+
86
+ # Interactive selection if project not provided
87
+ if project_name.nil?
88
+ worktrees = @worktree_manager.list_worktrees(session_name: session_name)
89
+ if worktrees.empty?
90
+ @ui.empty_state("No worktrees in current session")
91
+ suggest_add_worktree
92
+ return
93
+ end
94
+
95
+ choices = worktrees.map do |w|
96
+ { name: "#{w[:project]} (#{w[:branch]})", value: w[:project] }
97
+ end
98
+ project_name = @prompt.select("Select worktree to remove:", choices)
99
+ end
100
+
101
+ # Check for uncommitted changes unless forced
102
+ unless options[:force]
103
+ worktree = @worktree_manager.get_worktree(project_name, session_name: session_name)
104
+ if worktree && worktree[:status] != "clean" && !@prompt.ask_yes_no(
105
+ "Worktree has uncommitted changes. Continue?", default: false
106
+ )
107
+ @ui.info("Cancelled")
108
+ return
109
+ end
110
+ end
111
+
112
+ unless @prompt.confirm_deletion(project_name, "worktree")
113
+ @ui.info("Cancelled")
114
+ return
115
+ end
116
+
117
+ begin
118
+ @ui.progress_start("Removing worktree for #{project_name}")
119
+ @worktree_manager.remove_worktree(project_name, session_name: session_name)
120
+ @ui.progress_done
121
+ @ui.success("Removed worktree for #{project_name}")
122
+ rescue Sxn::Error => e
123
+ @ui.progress_failed
124
+ @ui.error(e.message)
125
+ exit(e.exit_code)
126
+ end
127
+ end
128
+
129
+ desc "list", "List worktrees in current session"
130
+ option :session, type: :string, aliases: "-s", desc: "Target session (defaults to current)"
131
+ option :validate, type: :boolean, aliases: "-v", desc: "Validate worktree status"
132
+ option :all_sessions, type: :boolean, aliases: "-a", desc: "List worktrees from all sessions"
133
+
134
+ def list
135
+ ensure_initialized!
136
+
137
+ if options[:all_sessions]
138
+ list_all_worktrees
139
+ else
140
+ list_session_worktrees
141
+ end
142
+ end
143
+
144
+ desc "validate PROJECT", "Validate a worktree"
145
+ option :session, type: :string, aliases: "-s", desc: "Target session (defaults to current)"
146
+
147
+ def validate(project_name = nil)
148
+ ensure_initialized!
149
+
150
+ session_name = options[:session] || @config_manager.current_session
151
+ unless session_name
152
+ @ui.error("No active session")
153
+ exit(1)
154
+ end
155
+
156
+ # Interactive selection if project not provided
157
+ if project_name.nil?
158
+ worktrees = @worktree_manager.list_worktrees(session_name: session_name)
159
+ if worktrees.empty?
160
+ @ui.empty_state("No worktrees in current session")
161
+ return
162
+ end
163
+
164
+ choices = worktrees.map do |w|
165
+ { name: "#{w[:project]} (#{w[:branch]})", value: w[:project] }
166
+ end
167
+ project_name = @prompt.select("Select worktree to validate:", choices)
168
+ end
169
+
170
+ begin
171
+ result = @worktree_manager.validate_worktree(project_name, session_name: session_name)
172
+
173
+ @ui.section("Worktree Validation: #{project_name}")
174
+
175
+ if result[:valid]
176
+ @ui.success("Worktree is valid")
177
+ else
178
+ @ui.error("Worktree has issues:")
179
+ result[:issues].each { |issue| @ui.list_item(issue) }
180
+ end
181
+
182
+ display_worktree_info(result[:worktree], detailed: true) if result[:worktree]
183
+ rescue Sxn::Error => e
184
+ @ui.error(e.message)
185
+ exit(e.exit_code)
186
+ end
187
+ end
188
+
189
+ desc "status", "Show status of all worktrees in current session"
190
+ option :session, type: :string, aliases: "-s", desc: "Target session (defaults to current)"
191
+
192
+ def status
193
+ ensure_initialized!
194
+
195
+ session_name = options[:session] || @config_manager.current_session
196
+ unless session_name
197
+ @ui.error("No active session")
198
+ exit(1)
199
+ end
200
+
201
+ begin
202
+ worktrees = @worktree_manager.list_worktrees(session_name: session_name)
203
+
204
+ @ui.section("Worktree Status - Session: #{session_name}")
205
+
206
+ if worktrees.empty?
207
+ @ui.empty_state("No worktrees in current session")
208
+ suggest_add_worktree
209
+ else
210
+ display_worktree_status(worktrees)
211
+ end
212
+ rescue Sxn::Error => e
213
+ @ui.error(e.message)
214
+ exit(e.exit_code)
215
+ end
216
+ end
217
+
218
+ private
219
+
220
+ def ensure_initialized!
221
+ return if @config_manager.initialized?
222
+
223
+ @ui.error("Project not initialized")
224
+ @ui.recovery_suggestion("Run 'sxn init' to initialize sxn in this project")
225
+ exit(1)
226
+ end
227
+
228
+ def select_project(message)
229
+ projects = @project_manager.list_projects
230
+ if projects.empty?
231
+ @ui.empty_state("No projects configured")
232
+ @ui.recovery_suggestion("Add projects with 'sxn projects add <name> <path>'")
233
+ return nil
234
+ end
235
+
236
+ choices = projects.map do |p|
237
+ { name: "#{p[:name]} (#{p[:type]}) - #{p[:path]}", value: p[:name] }
238
+ end
239
+ @prompt.select(message, choices)
240
+ end
241
+
242
+ def list_session_worktrees
243
+ session_name = options[:session] || @config_manager.current_session
244
+ unless session_name
245
+ @ui.error("No active session")
246
+ @ui.recovery_suggestion("Use 'sxn use <session>' or specify --session")
247
+ exit(1)
248
+ end
249
+
250
+ begin
251
+ worktrees = @worktree_manager.list_worktrees(session_name: session_name)
252
+
253
+ @ui.section("Worktrees - Session: #{session_name}")
254
+
255
+ if worktrees.empty?
256
+ @ui.empty_state("No worktrees in current session")
257
+ suggest_add_worktree
258
+ elsif options[:validate]
259
+ list_with_validation(worktrees, session_name)
260
+ else
261
+ @table.worktrees(worktrees)
262
+ @ui.newline
263
+ @ui.info("Total: #{worktrees.size} worktrees")
264
+ end
265
+ rescue Sxn::Error => e
266
+ @ui.error(e.message)
267
+ exit(e.exit_code)
268
+ end
269
+ end
270
+
271
+ def list_all_worktrees
272
+ sessions = @session_manager.list_sessions(status: "active")
273
+
274
+ @ui.section("All Worktrees")
275
+
276
+ if sessions.empty?
277
+ @ui.empty_state("No active sessions")
278
+ return
279
+ end
280
+
281
+ sessions.each do |session|
282
+ worktrees = @worktree_manager.list_worktrees(session_name: session[:name])
283
+ next if worktrees.empty?
284
+
285
+ @ui.subsection("Session: #{session[:name]}")
286
+ @table.worktrees(worktrees)
287
+ @ui.newline
288
+ end
289
+ rescue Sxn::Error => e
290
+ @ui.error(e.message)
291
+ exit(e.exit_code)
292
+ end
293
+
294
+ def list_with_validation(worktrees, session_name)
295
+ @ui.subsection("Worktree Validation")
296
+
297
+ Sxn::UI::ProgressBar.with_progress("Validating worktrees", worktrees) do |worktree, progress|
298
+ validation = @worktree_manager.validate_worktree(
299
+ worktree[:project],
300
+ session_name: session_name
301
+ )
302
+
303
+ status = validation[:valid] ? "✅" : "❌"
304
+ progress.log("#{status} #{worktree[:project]}")
305
+
306
+ unless validation[:valid]
307
+ validation[:issues].each do |issue|
308
+ progress.log(" - #{issue}")
309
+ end
310
+ end
311
+
312
+ validation
313
+ end
314
+
315
+ @ui.newline
316
+ @table.worktrees(worktrees)
317
+ end
318
+
319
+ def display_worktree_info(worktree, detailed: false)
320
+ @ui.newline
321
+ @ui.key_value("Project", worktree[:project])
322
+ @ui.key_value("Branch", worktree[:branch])
323
+ @ui.key_value("Path", worktree[:path])
324
+ @ui.key_value("Session", worktree[:session]) if worktree[:session]
325
+
326
+ if detailed
327
+ @ui.key_value("Created", worktree[:created_at]) if worktree[:created_at]
328
+ @ui.key_value("Exists", worktree[:exists] ? "Yes" : "No")
329
+ @ui.key_value("Status", worktree[:status]) if worktree[:status]
330
+ end
331
+
332
+ @ui.newline
333
+ display_worktree_commands(worktree)
334
+ end
335
+
336
+ def display_worktree_commands(worktree)
337
+ @ui.subsection("Available Commands")
338
+
339
+ @ui.command_example(
340
+ "cd #{worktree[:path]}",
341
+ "Navigate to worktree directory"
342
+ )
343
+
344
+ if worktree[:project]
345
+ @ui.command_example(
346
+ "sxn rules apply #{worktree[:project]}",
347
+ "Apply project rules to this worktree"
348
+ )
349
+ end
350
+
351
+ @ui.command_example(
352
+ "sxn worktree validate #{worktree[:project]}",
353
+ "Validate this worktree"
354
+ )
355
+ end
356
+
357
+ def display_worktree_status(worktrees)
358
+ clean_count = worktrees.count { |w| w[:status] == "clean" }
359
+ modified_count = worktrees.count { |w| w[:status] == "modified" }
360
+ missing_count = worktrees.count { |w| !w[:exists] }
361
+
362
+ @table.worktrees(worktrees)
363
+ @ui.newline
364
+
365
+ @ui.info("Summary:")
366
+ @ui.key_value(" Clean", clean_count, indent: 2)
367
+ @ui.key_value(" Modified", modified_count, indent: 2) if modified_count.positive?
368
+ @ui.key_value(" Missing", missing_count, indent: 2) if missing_count.positive?
369
+ @ui.key_value(" Total", worktrees.size, indent: 2)
370
+
371
+ if modified_count.positive?
372
+ @ui.newline
373
+ @ui.warning("#{modified_count} worktrees have uncommitted changes")
374
+ end
375
+
376
+ return unless missing_count.positive?
377
+
378
+ @ui.newline
379
+ @ui.error("#{missing_count} worktrees are missing from filesystem")
380
+ end
381
+
382
+ def apply_project_rules(project_name, session_name)
383
+ rules_manager = Sxn::Core::RulesManager.new(@config_manager, @project_manager)
384
+
385
+ begin
386
+ @ui.newline
387
+ @ui.subsection("Applying Project Rules")
388
+
389
+ @ui.progress_start("Applying rules for #{project_name}")
390
+ results = rules_manager.apply_rules(project_name, session_name)
391
+ @ui.progress_done
392
+
393
+ if results[:success]
394
+ @ui.success("Applied #{results[:applied_count]} rules successfully")
395
+ else
396
+ @ui.warning("Some rules failed to apply")
397
+ results[:errors].each { |error| @ui.error(" #{error}") }
398
+ end
399
+ rescue StandardError => e
400
+ @ui.warning("Could not apply rules: #{e.message}")
401
+ @ui.recovery_suggestion("Apply rules manually with 'sxn rules apply #{project_name}'")
402
+ end
403
+ end
404
+
405
+ def suggest_add_worktree
406
+ current_session = @config_manager.current_session
407
+ @ui.newline
408
+ if current_session
409
+ @ui.recovery_suggestion("Add worktrees with 'sxn worktree add <project> [branch]'")
410
+ else
411
+ @ui.recovery_suggestion("Create and activate a session first with 'sxn add <session>'")
412
+ end
413
+ end
414
+ end
415
+ end
416
+ end