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.
- checksums.yaml +7 -0
- data/.gem_rbs_collection/addressable/2.8/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/addressable/2.8/addressable.rbs +62 -0
- data/.gem_rbs_collection/async/2.12/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/async/2.12/async.rbs +119 -0
- data/.gem_rbs_collection/async/2.12/kernel.rbs +5 -0
- data/.gem_rbs_collection/async/2.12/manifest.yaml +7 -0
- data/.gem_rbs_collection/bcrypt/3.1/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/bcrypt/3.1/bcrypt.rbs +47 -0
- data/.gem_rbs_collection/bcrypt/3.1/manifest.yaml +2 -0
- data/.gem_rbs_collection/bigdecimal/3.1/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/bigdecimal/3.1/bigdecimal-math.rbs +119 -0
- data/.gem_rbs_collection/bigdecimal/3.1/bigdecimal.rbs +1630 -0
- data/.gem_rbs_collection/concurrent-ruby/1.1/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/concurrent-ruby/1.1/array.rbs +4 -0
- data/.gem_rbs_collection/concurrent-ruby/1.1/executor.rbs +26 -0
- data/.gem_rbs_collection/concurrent-ruby/1.1/hash.rbs +4 -0
- data/.gem_rbs_collection/concurrent-ruby/1.1/map.rbs +65 -0
- data/.gem_rbs_collection/concurrent-ruby/1.1/promises.rbs +249 -0
- data/.gem_rbs_collection/concurrent-ruby/1.1/utility/processor_counter.rbs +5 -0
- data/.gem_rbs_collection/diff-lcs/1.5/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/diff-lcs/1.5/diff-lcs.rbs +11 -0
- data/.gem_rbs_collection/listen/3.9/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/listen/3.9/listen.rbs +25 -0
- data/.gem_rbs_collection/listen/3.9/listener.rbs +24 -0
- data/.gem_rbs_collection/mini_mime/0.1/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/mini_mime/0.1/mini_mime.rbs +14 -0
- data/.gem_rbs_collection/parallel/1.20/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/parallel/1.20/parallel.rbs +86 -0
- data/.gem_rbs_collection/rake/13.0/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/rake/13.0/manifest.yaml +2 -0
- data/.gem_rbs_collection/rake/13.0/rake.rbs +39 -0
- data/.gem_rbs_collection/rubocop-ast/1.46/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/rubocop-ast/1.46/rubocop-ast.rbs +822 -0
- data/.gem_rbs_collection/sqlite3/2.0/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/sqlite3/2.0/database.rbs +20 -0
- data/.gem_rbs_collection/sqlite3/2.0/pragmas.rbs +5 -0
- data/.rspec +4 -0
- data/.rubocop.yml +121 -0
- data/.simplecov +51 -0
- data/CHANGELOG.md +49 -0
- data/Gemfile +24 -0
- data/Gemfile.lock +329 -0
- data/LICENSE.txt +21 -0
- data/README.md +225 -0
- data/Rakefile +54 -0
- data/Steepfile +50 -0
- data/bin/sxn +6 -0
- data/lib/sxn/CLI.rb +275 -0
- data/lib/sxn/commands/init.rb +137 -0
- data/lib/sxn/commands/projects.rb +350 -0
- data/lib/sxn/commands/rules.rb +435 -0
- data/lib/sxn/commands/sessions.rb +300 -0
- data/lib/sxn/commands/worktrees.rb +416 -0
- data/lib/sxn/commands.rb +13 -0
- data/lib/sxn/config/config_cache.rb +295 -0
- data/lib/sxn/config/config_discovery.rb +242 -0
- data/lib/sxn/config/config_validator.rb +562 -0
- data/lib/sxn/config.rb +259 -0
- data/lib/sxn/core/config_manager.rb +290 -0
- data/lib/sxn/core/project_manager.rb +307 -0
- data/lib/sxn/core/rules_manager.rb +306 -0
- data/lib/sxn/core/session_manager.rb +336 -0
- data/lib/sxn/core/worktree_manager.rb +281 -0
- data/lib/sxn/core.rb +13 -0
- data/lib/sxn/database/errors.rb +29 -0
- data/lib/sxn/database/session_database.rb +691 -0
- data/lib/sxn/database.rb +24 -0
- data/lib/sxn/errors.rb +76 -0
- data/lib/sxn/rules/base_rule.rb +367 -0
- data/lib/sxn/rules/copy_files_rule.rb +346 -0
- data/lib/sxn/rules/errors.rb +28 -0
- data/lib/sxn/rules/project_detector.rb +871 -0
- data/lib/sxn/rules/rules_engine.rb +485 -0
- data/lib/sxn/rules/setup_commands_rule.rb +307 -0
- data/lib/sxn/rules/template_rule.rb +262 -0
- data/lib/sxn/rules.rb +148 -0
- data/lib/sxn/runtime_validations.rb +96 -0
- data/lib/sxn/security/secure_command_executor.rb +364 -0
- data/lib/sxn/security/secure_file_copier.rb +478 -0
- data/lib/sxn/security/secure_path_validator.rb +258 -0
- data/lib/sxn/security.rb +15 -0
- data/lib/sxn/templates/common/gitignore.liquid +99 -0
- data/lib/sxn/templates/common/session-info.md.liquid +58 -0
- data/lib/sxn/templates/errors.rb +36 -0
- data/lib/sxn/templates/javascript/README.md.liquid +59 -0
- data/lib/sxn/templates/javascript/session-info.md.liquid +206 -0
- data/lib/sxn/templates/rails/CLAUDE.md.liquid +78 -0
- data/lib/sxn/templates/rails/database.yml.liquid +31 -0
- data/lib/sxn/templates/rails/session-info.md.liquid +144 -0
- data/lib/sxn/templates/template_engine.rb +346 -0
- data/lib/sxn/templates/template_processor.rb +279 -0
- data/lib/sxn/templates/template_security.rb +410 -0
- data/lib/sxn/templates/template_variables.rb +713 -0
- data/lib/sxn/templates.rb +28 -0
- data/lib/sxn/ui/output.rb +103 -0
- data/lib/sxn/ui/progress_bar.rb +91 -0
- data/lib/sxn/ui/prompt.rb +116 -0
- data/lib/sxn/ui/table.rb +183 -0
- data/lib/sxn/ui.rb +12 -0
- data/lib/sxn/version.rb +5 -0
- data/lib/sxn.rb +63 -0
- data/rbs_collection.lock.yaml +180 -0
- data/rbs_collection.yaml +39 -0
- data/scripts/test.sh +31 -0
- data/sig/external/liquid.rbs +116 -0
- data/sig/external/thor.rbs +99 -0
- data/sig/external/tty.rbs +71 -0
- data/sig/sxn/cli.rbs +46 -0
- data/sig/sxn/commands/init.rbs +38 -0
- data/sig/sxn/commands/projects.rbs +72 -0
- data/sig/sxn/commands/rules.rbs +95 -0
- data/sig/sxn/commands/sessions.rbs +62 -0
- data/sig/sxn/commands/worktrees.rbs +82 -0
- data/sig/sxn/commands.rbs +6 -0
- data/sig/sxn/config/config_cache.rbs +67 -0
- data/sig/sxn/config/config_discovery.rbs +64 -0
- data/sig/sxn/config/config_validator.rbs +64 -0
- data/sig/sxn/config.rbs +74 -0
- data/sig/sxn/core/config_manager.rbs +67 -0
- data/sig/sxn/core/project_manager.rbs +52 -0
- data/sig/sxn/core/rules_manager.rbs +54 -0
- data/sig/sxn/core/session_manager.rbs +59 -0
- data/sig/sxn/core/worktree_manager.rbs +50 -0
- data/sig/sxn/core.rbs +87 -0
- data/sig/sxn/database/errors.rbs +37 -0
- data/sig/sxn/database/session_database.rbs +151 -0
- data/sig/sxn/database.rbs +83 -0
- data/sig/sxn/errors.rbs +89 -0
- data/sig/sxn/rules/base_rule.rbs +137 -0
- data/sig/sxn/rules/copy_files_rule.rbs +65 -0
- data/sig/sxn/rules/errors.rbs +33 -0
- data/sig/sxn/rules/project_detector.rbs +115 -0
- data/sig/sxn/rules/rules_engine.rbs +118 -0
- data/sig/sxn/rules/setup_commands_rule.rbs +60 -0
- data/sig/sxn/rules/template_rule.rbs +44 -0
- data/sig/sxn/rules.rbs +287 -0
- data/sig/sxn/runtime_validations.rbs +16 -0
- data/sig/sxn/security/secure_command_executor.rbs +63 -0
- data/sig/sxn/security/secure_file_copier.rbs +79 -0
- data/sig/sxn/security/secure_path_validator.rbs +30 -0
- data/sig/sxn/security.rbs +128 -0
- data/sig/sxn/templates/errors.rbs +43 -0
- data/sig/sxn/templates/template_engine.rbs +50 -0
- data/sig/sxn/templates/template_processor.rbs +44 -0
- data/sig/sxn/templates/template_security.rbs +62 -0
- data/sig/sxn/templates/template_variables.rbs +103 -0
- data/sig/sxn/templates.rbs +104 -0
- data/sig/sxn/ui/output.rbs +50 -0
- data/sig/sxn/ui/progress_bar.rbs +39 -0
- data/sig/sxn/ui/prompt.rbs +38 -0
- data/sig/sxn/ui/table.rbs +43 -0
- data/sig/sxn/ui.rbs +63 -0
- data/sig/sxn/version.rbs +5 -0
- data/sig/sxn.rbs +29 -0
- 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
|