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,336 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "time"
5
+ require "shellwords"
6
+
7
+ module Sxn
8
+ module Core
9
+ # Manages session lifecycle and operations
10
+ class SessionManager
11
+ def initialize(config_manager = nil)
12
+ @config_manager = config_manager || ConfigManager.new
13
+ @database = initialize_database
14
+ end
15
+
16
+ def create_session(name, description: nil, linear_task: nil)
17
+ validate_session_name!(name)
18
+ ensure_sessions_folder_exists!
19
+
20
+ raise Sxn::SessionAlreadyExistsError, "Session '#{name}' already exists" if session_exists?(name)
21
+
22
+ session_id = SecureRandom.uuid
23
+ session_path = File.join(@config_manager.sessions_folder_path, name)
24
+
25
+ # Create session directory
26
+ FileUtils.mkdir_p(session_path)
27
+
28
+ # Create session record
29
+ session_data = {
30
+ id: session_id,
31
+ name: name,
32
+ path: session_path,
33
+ created_at: Time.now.iso8601,
34
+ updated_at: Time.now.iso8601,
35
+ status: "active",
36
+ description: description,
37
+ linear_task: linear_task,
38
+ projects: [],
39
+ worktrees: {}
40
+ }
41
+
42
+ @database.create_session(session_data)
43
+ session_data
44
+ rescue Sxn::Database::DuplicateSessionError => e
45
+ raise Sxn::SessionAlreadyExistsError, e.message
46
+ end
47
+
48
+ def remove_session(name, force: false)
49
+ session = get_session(name)
50
+ raise Sxn::SessionNotFoundError, "Session '#{name}' not found" unless session
51
+
52
+ unless force
53
+ # Check for uncommitted changes in worktrees
54
+ uncommitted_worktrees = find_uncommitted_worktrees(session)
55
+ unless uncommitted_worktrees.empty?
56
+ raise Sxn::SessionHasChangesError,
57
+ "Session has uncommitted changes in: #{uncommitted_worktrees.join(", ")}"
58
+ end
59
+ end
60
+
61
+ # Remove worktrees first
62
+ remove_session_worktrees(session)
63
+
64
+ # Remove session directory
65
+ FileUtils.rm_rf(session[:path])
66
+
67
+ # Remove from database
68
+ @database.delete_session(session[:id])
69
+
70
+ # Clear current session if it was this one
71
+ @config_manager.update_current_session(nil) if @config_manager.current_session == name
72
+
73
+ true
74
+ end
75
+
76
+ def list_sessions(status: nil, limit: 100, filters: nil, **options)
77
+ # Support both the filters parameter and individual status parameter
78
+ filter_hash = filters || {}
79
+ filter_hash[:status] = status if status
80
+
81
+ # Merge any other options
82
+ filter_hash.merge!(options) if options.any?
83
+
84
+ sessions = @database.list_sessions(filters: filter_hash, limit: limit)
85
+ sessions.map { |s| format_session_data(s) }
86
+ end
87
+
88
+ def get_session(name)
89
+ session = @database.get_session_by_name(name)
90
+ session ? format_session_data(session) : nil
91
+ rescue Sxn::Database::SessionNotFoundError
92
+ nil
93
+ end
94
+
95
+ def use_session(name)
96
+ session = get_session(name)
97
+ raise Sxn::SessionNotFoundError, "Session '#{name}' not found" unless session
98
+
99
+ @config_manager.update_current_session(name)
100
+
101
+ # Update session status to active
102
+ update_session_status(session[:id], "active")
103
+
104
+ session
105
+ end
106
+
107
+ def current_session
108
+ current_name = @config_manager.current_session
109
+ return nil unless current_name
110
+
111
+ get_session(current_name)
112
+ end
113
+
114
+ def session_exists?(name)
115
+ !get_session(name).nil?
116
+ end
117
+
118
+ def add_worktree_to_session(session_name, project_name, worktree_path, branch)
119
+ session = get_session(session_name)
120
+ raise Sxn::SessionNotFoundError, "Session '#{session_name}' not found" unless session
121
+
122
+ # Update session metadata
123
+ session_data = @database.get_session_by_id(session[:id])
124
+ worktrees = session_data[:worktrees] || {}
125
+ worktrees[project_name] = {
126
+ path: worktree_path,
127
+ branch: branch,
128
+ created_at: Time.now.iso8601
129
+ }
130
+
131
+ projects = session_data[:projects] || []
132
+ projects << project_name unless projects.include?(project_name)
133
+
134
+ @database.update_session(session[:id], {
135
+ worktrees: worktrees,
136
+ projects: projects.uniq
137
+ })
138
+ end
139
+
140
+ def remove_worktree_from_session(session_name, project_name)
141
+ session = get_session(session_name)
142
+ raise Sxn::SessionNotFoundError, "Session '#{session_name}' not found" unless session
143
+
144
+ session_data = @database.get_session_by_id(session[:id])
145
+ worktrees = session_data[:worktrees] || {}
146
+ worktrees.delete(project_name)
147
+
148
+ projects = session_data[:projects] || []
149
+ projects.delete(project_name)
150
+
151
+ @database.update_session(session[:id], {
152
+ worktrees: worktrees,
153
+ projects: projects
154
+ })
155
+ end
156
+
157
+ def get_session_worktrees(session_name)
158
+ session = get_session(session_name)
159
+ return {} unless session
160
+
161
+ session_data = @database.get_session_by_id(session[:id])
162
+ session_data[:worktrees] || {}
163
+ end
164
+
165
+ def archive_session(name)
166
+ update_session_status_by_name(name, "archived")
167
+ true
168
+ end
169
+
170
+ def activate_session(name)
171
+ update_session_status_by_name(name, "active")
172
+ true
173
+ end
174
+
175
+ def cleanup_old_sessions(days_old = 30)
176
+ cutoff_date = Time.now.utc - (days_old * 24 * 60 * 60)
177
+ old_sessions = @database.list_sessions.select do |session|
178
+ session_time = Time.parse(session[:updated_at]).utc
179
+ session_time < cutoff_date
180
+ rescue ArgumentError
181
+ # If we can't parse the time, err on the side of caution and don't delete
182
+ false
183
+ end
184
+
185
+ old_sessions.each do |session|
186
+ remove_session(session[:name], force: true)
187
+ end
188
+
189
+ old_sessions.length
190
+ end
191
+
192
+ private
193
+
194
+ def initialize_database
195
+ raise Sxn::ConfigurationError, "Project not initialized. Run 'sxn init' first." unless @config_manager.initialized?
196
+
197
+ db_path = File.join(File.dirname(@config_manager.config_path), "sessions.db")
198
+ Sxn::Database::SessionDatabase.new(db_path)
199
+ end
200
+
201
+ def validate_session_name!(name)
202
+ return if name.match?(/\A[a-zA-Z0-9_-]+\z/)
203
+
204
+ raise Sxn::InvalidSessionNameError,
205
+ "Session name must contain only letters, numbers, hyphens, and underscores"
206
+ end
207
+
208
+ def ensure_sessions_folder_exists!
209
+ sessions_folder = @config_manager.sessions_folder_path
210
+ raise Sxn::ConfigurationError, "Sessions folder not configured" unless sessions_folder
211
+
212
+ FileUtils.mkdir_p(sessions_folder)
213
+ end
214
+
215
+ def format_session_data(db_row)
216
+ metadata = db_row[:metadata] || {}
217
+
218
+ {
219
+ id: db_row[:id],
220
+ name: db_row[:name],
221
+ path: File.join(@config_manager.sessions_folder_path, db_row[:name]),
222
+ created_at: db_row[:created_at],
223
+ updated_at: db_row[:updated_at],
224
+ status: db_row[:status],
225
+ description: metadata["description"] || db_row[:description],
226
+ linear_task: metadata["linear_task"] || db_row[:linear_task],
227
+ # Support both metadata and database columns for backward compatibility
228
+ projects: db_row[:projects] || metadata["projects"] || [],
229
+ worktrees: db_row[:worktrees] || metadata["worktrees"] || {}
230
+ }
231
+ end
232
+
233
+ def update_session_status(session_id, status, **additional_options)
234
+ updates = { status: status }
235
+
236
+ # Put additional options into metadata if provided
237
+ if additional_options.any?
238
+ current_session = @database.get_session(session_id)
239
+ current_metadata = current_session[:metadata] || {}
240
+ updates[:metadata] = current_metadata.merge(additional_options)
241
+ end
242
+
243
+ @database.update_session(session_id, updates)
244
+ end
245
+
246
+ def update_session_status_by_name(name, status, **additional_options)
247
+ session = get_session(name)
248
+ raise Sxn::SessionNotFoundError, "Session '#{name}' not found" unless session
249
+
250
+ updates = { status: status }
251
+
252
+ # Put additional options into metadata if provided
253
+ if additional_options.any?
254
+ current_session = @database.get_session(session[:id])
255
+ current_metadata = current_session[:metadata] || {}
256
+ updates[:metadata] = current_metadata.merge(additional_options)
257
+ end
258
+
259
+ @database.update_session(session[:id], updates)
260
+ end
261
+
262
+ def find_uncommitted_worktrees(session)
263
+ worktrees = session[:worktrees] || {}
264
+ uncommitted = []
265
+
266
+ worktrees.each do |project, worktree_info|
267
+ path = worktree_info[:path] || worktree_info["path"]
268
+
269
+ # If directory doesn't exist, skip it (not uncommitted, just missing)
270
+ next unless File.directory?(path)
271
+
272
+ begin
273
+ Dir.chdir(path) do
274
+ # Check for staged changes
275
+ staged = !system("git diff-index --quiet --cached HEAD", out: File::NULL, err: File::NULL)
276
+ # Check for unstaged changes
277
+ unstaged = !system("git diff-files --quiet", out: File::NULL, err: File::NULL)
278
+ # Check for untracked files
279
+ untracked_output = `git ls-files --others --exclude-standard 2>/dev/null`
280
+ untracked = !untracked_output.empty?
281
+
282
+ uncommitted << project if staged || unstaged || untracked
283
+ end
284
+ rescue StandardError
285
+ # If we can't check git status, assume it has changes to be safe
286
+ uncommitted << project
287
+ end
288
+ end
289
+
290
+ uncommitted
291
+ end
292
+
293
+ def remove_session_worktrees(session)
294
+ worktrees = session[:worktrees] || {}
295
+
296
+ worktrees.each do |project, worktree_info|
297
+ path = worktree_info[:path] || worktree_info["path"]
298
+ next unless File.directory?(path)
299
+
300
+ begin
301
+ # Remove git worktree
302
+ parent_repo = find_parent_repository(path)
303
+ if parent_repo
304
+ Dir.chdir(parent_repo) do
305
+ success = system("git worktree remove #{Shellwords.escape(path)}",
306
+ out: File::NULL, err: File::NULL)
307
+ warn "Warning: Could not cleanly remove git worktree for #{project}: git command failed" unless success
308
+ end
309
+ end
310
+ rescue StandardError => e
311
+ # Log error but continue with removal
312
+ warn "Warning: Could not cleanly remove git worktree for #{project}: #{e.message}"
313
+ end
314
+
315
+ # Remove directory if it still exists
316
+ FileUtils.rm_rf(path)
317
+ end
318
+ end
319
+
320
+ def find_parent_repository(worktree_path)
321
+ # Try to find the parent repository by looking for .git/worktrees reference
322
+ git_file = File.join(worktree_path, ".git")
323
+ return nil unless File.exist?(git_file)
324
+
325
+ content = File.read(git_file).strip
326
+ if content.start_with?("gitdir:")
327
+ git_dir = content.sub(/^gitdir:\s*/, "")
328
+ # Extract parent repo from worktrees path
329
+ git_dir.split("/worktrees/").first if git_dir.include?("/worktrees/")
330
+ end
331
+ rescue StandardError
332
+ nil
333
+ end
334
+ end
335
+ end
336
+ end
@@ -0,0 +1,281 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "English"
4
+ require "shellwords"
5
+ require "fileutils"
6
+
7
+ module Sxn
8
+ module Core
9
+ # Manages git worktree operations
10
+ class WorktreeManager
11
+ def initialize(config_manager = nil, session_manager = nil)
12
+ @config_manager = config_manager || ConfigManager.new
13
+ @session_manager = session_manager || SessionManager.new(@config_manager)
14
+ @project_manager = ProjectManager.new(@config_manager)
15
+ end
16
+
17
+ def add_worktree(project_name, branch = nil, session_name: nil)
18
+ # Use current session if not specified
19
+ session_name ||= @config_manager.current_session
20
+ raise Sxn::NoActiveSessionError, "No active session. Use 'sxn use <session>' first." unless session_name
21
+
22
+ session = @session_manager.get_session(session_name)
23
+ raise Sxn::SessionNotFoundError, "Session '#{session_name}' not found" unless session
24
+
25
+ project = @project_manager.get_project(project_name)
26
+ raise Sxn::ProjectNotFoundError, "Project '#{project_name}' not found" unless project
27
+
28
+ # Use default branch if not specified
29
+ branch ||= project[:default_branch] || "master"
30
+
31
+ # Check if worktree already exists in this session
32
+ existing_worktrees = @session_manager.get_session_worktrees(session_name)
33
+ if existing_worktrees[project_name]
34
+ raise Sxn::WorktreeExistsError,
35
+ "Worktree for '#{project_name}' already exists in session '#{session_name}'"
36
+ end
37
+
38
+ # Create worktree path
39
+ worktree_path = File.join(session[:path], project_name)
40
+
41
+ begin
42
+ # Create the worktree
43
+ create_git_worktree(project[:path], worktree_path, branch)
44
+
45
+ # Register worktree with session
46
+ @session_manager.add_worktree_to_session(session_name, project_name, worktree_path, branch)
47
+
48
+ {
49
+ project: project_name,
50
+ branch: branch,
51
+ path: worktree_path,
52
+ session: session_name
53
+ }
54
+ rescue StandardError => e
55
+ # Clean up on failure
56
+ FileUtils.rm_rf(worktree_path)
57
+ raise Sxn::WorktreeCreationError, "Failed to create worktree: #{e.message}"
58
+ end
59
+ end
60
+
61
+ def remove_worktree(project_name, session_name: nil)
62
+ # Use current session if not specified
63
+ session_name ||= @config_manager.current_session
64
+ raise Sxn::NoActiveSessionError, "No active session. Use 'sxn use <session>' first." unless session_name
65
+
66
+ session = @session_manager.get_session(session_name)
67
+ raise Sxn::SessionNotFoundError, "Session '#{session_name}' not found" unless session
68
+
69
+ worktrees = @session_manager.get_session_worktrees(session_name)
70
+ worktree_info = worktrees[project_name]
71
+ unless worktree_info
72
+ raise Sxn::WorktreeNotFoundError,
73
+ "Worktree for '#{project_name}' not found in session '#{session_name}'"
74
+ end
75
+
76
+ worktree_path = worktree_info[:path] || worktree_info["path"]
77
+
78
+ begin
79
+ # Remove git worktree
80
+ project = @project_manager.get_project(project_name)
81
+ if project
82
+ remove_git_worktree(project[:path], worktree_path)
83
+ else
84
+ # Project might have been removed, try to find parent repo
85
+ remove_git_worktree_by_path(worktree_path)
86
+ end
87
+
88
+ # Remove from session
89
+ @session_manager.remove_worktree_from_session(session_name, project_name)
90
+
91
+ true
92
+ rescue StandardError => e
93
+ raise Sxn::WorktreeRemovalError, "Failed to remove worktree: #{e.message}"
94
+ end
95
+ end
96
+
97
+ def list_worktrees(session_name: nil)
98
+ # Use current session if not specified
99
+ session_name ||= @config_manager.current_session
100
+ return [] unless session_name
101
+
102
+ session = @session_manager.get_session(session_name)
103
+ return [] unless session
104
+
105
+ worktrees_data = @session_manager.get_session_worktrees(session_name)
106
+
107
+ worktrees_data.map do |project_name, worktree_info|
108
+ {
109
+ project: project_name,
110
+ branch: worktree_info[:branch] || worktree_info["branch"],
111
+ path: worktree_info[:path] || worktree_info["path"],
112
+ created_at: worktree_info[:created_at] || worktree_info["created_at"],
113
+ exists: File.directory?(worktree_info[:path] || worktree_info["path"]),
114
+ status: get_worktree_status(worktree_info[:path] || worktree_info["path"])
115
+ }
116
+ end
117
+ end
118
+
119
+ def get_worktree(project_name, session_name: nil)
120
+ worktrees = list_worktrees(session_name: session_name)
121
+ worktrees.find { |w| w[:project] == project_name }
122
+ end
123
+
124
+ # Check if a worktree exists for a project
125
+ def worktree_exists?(project_name, session_name: nil)
126
+ get_worktree(project_name, session_name: session_name) != nil
127
+ end
128
+
129
+ # Get the path to a worktree for a project
130
+ def worktree_path(project_name, session_name: nil)
131
+ worktree = get_worktree(project_name, session_name: session_name)
132
+ worktree&.fetch(:path, nil)
133
+ end
134
+
135
+ # Validate worktree name (expected by tests)
136
+ def validate_worktree_name(name)
137
+ return true if name.match?(/\A[a-zA-Z0-9_-]+\z/)
138
+
139
+ raise Sxn::WorktreeError, "Invalid worktree name: #{name}"
140
+ end
141
+
142
+ # Execute git command (mock point for tests)
143
+ def execute_git_command(*)
144
+ system(*)
145
+ end
146
+
147
+ def validate_worktree(project_name, session_name: nil)
148
+ worktree = get_worktree(project_name, session_name: session_name)
149
+ return { valid: false, issues: ["Worktree not found"] } unless worktree
150
+
151
+ issues = []
152
+
153
+ # Check if directory exists
154
+ issues << "Worktree directory does not exist: #{worktree[:path]}" unless File.directory?(worktree[:path])
155
+
156
+ # Check if it's a valid git worktree
157
+ issues << "Directory is not a valid git worktree" unless valid_git_worktree?(worktree[:path])
158
+
159
+ # Check for git issues
160
+ git_issues = check_git_status(worktree[:path])
161
+ issues.concat(git_issues)
162
+
163
+ {
164
+ valid: issues.empty?,
165
+ issues: issues,
166
+ worktree: worktree
167
+ }
168
+ end
169
+
170
+ private
171
+
172
+ def create_git_worktree(project_path, worktree_path, branch)
173
+ Dir.chdir(project_path) do
174
+ # Check if branch exists
175
+ branch_exists = system("git show-ref --verify --quiet refs/heads/#{Shellwords.escape(branch)}",
176
+ out: File::NULL, err: File::NULL)
177
+
178
+ cmd = if branch_exists
179
+ # Branch exists, create worktree from existing branch
180
+ ["git", "worktree", "add", worktree_path, branch]
181
+ else
182
+ # Branch doesn't exist, create new branch
183
+ ["git", "worktree", "add", "-b", branch, worktree_path]
184
+ end
185
+
186
+ success = system(*cmd, out: File::NULL, err: File::NULL)
187
+ raise "Git worktree command failed" unless success
188
+ end
189
+ end
190
+
191
+ def remove_git_worktree(project_path, worktree_path)
192
+ Dir.chdir(project_path) do
193
+ # Remove worktree
194
+ cmd = ["git", "worktree", "remove", "--force", worktree_path]
195
+ system(*cmd, out: File::NULL, err: File::NULL)
196
+ end
197
+
198
+ # Clean up directory if it still exists
199
+ FileUtils.rm_rf(worktree_path)
200
+ end
201
+
202
+ def remove_git_worktree_by_path(worktree_path)
203
+ # Try to find parent repository from .git file
204
+ git_file = File.join(worktree_path, ".git")
205
+ if File.exist?(git_file)
206
+ content = File.read(git_file).strip
207
+ if content.start_with?("gitdir:")
208
+ git_dir = content.sub(/^gitdir:\s*/, "")
209
+ parent_repo = git_dir.split("/worktrees/").first if git_dir.include?("/worktrees/")
210
+
211
+ # Ensure parent_repo is a valid absolute path and the directory exists
212
+ if parent_repo && File.absolute_path?(parent_repo) && File.directory?(parent_repo)
213
+ begin
214
+ Dir.chdir(parent_repo) do
215
+ cmd = ["git", "worktree", "remove", "--force", worktree_path]
216
+ system(*cmd, out: File::NULL, err: File::NULL)
217
+ end
218
+ rescue Errno::ENOENT
219
+ # Directory doesn't exist or can't be accessed, skip git command
220
+ end
221
+ end
222
+ end
223
+ end
224
+
225
+ # Clean up directory
226
+ FileUtils.rm_rf(worktree_path)
227
+ end
228
+
229
+ def get_worktree_status(path)
230
+ return "missing" unless File.directory?(path)
231
+ return "invalid" unless valid_git_worktree?(path)
232
+
233
+ Dir.chdir(path) do
234
+ # Check for staged changes
235
+ staged = !system("git diff-index --quiet --cached HEAD", out: File::NULL, err: File::NULL)
236
+ return "staged" if staged
237
+
238
+ # Check for unstaged changes
239
+ unstaged = !system("git diff-files --quiet", out: File::NULL, err: File::NULL)
240
+ return "modified" if unstaged
241
+
242
+ # Check for untracked files
243
+ untracked = !system("git ls-files --others --exclude-standard --quiet", out: File::NULL, err: File::NULL)
244
+ return "untracked" if untracked
245
+
246
+ "clean"
247
+ end
248
+ rescue StandardError
249
+ "error"
250
+ end
251
+
252
+ def valid_git_worktree?(path)
253
+ File.exist?(File.join(path, ".git"))
254
+ end
255
+
256
+ def check_git_status(path)
257
+ return ["Directory does not exist"] unless File.directory?(path)
258
+ return ["Not a git repository"] unless valid_git_worktree?(path)
259
+
260
+ issues = []
261
+
262
+ begin
263
+ Dir.chdir(path) do
264
+ # Check if we can access git status
265
+ unless system("git status --porcelain", out: File::NULL, err: File::NULL)
266
+ issues << "Cannot access git status (possible repository corruption)"
267
+ end
268
+
269
+ # Check for detached HEAD
270
+ `git symbolic-ref -q HEAD 2>/dev/null`
271
+ issues << "Repository is in detached HEAD state" if $CHILD_STATUS.exitstatus != 0
272
+ end
273
+ rescue StandardError => e
274
+ issues << "Error checking git status: #{e.message}"
275
+ end
276
+
277
+ issues
278
+ end
279
+ end
280
+ end
281
+ end
data/lib/sxn/core.rb ADDED
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "core/config_manager"
4
+ require_relative "core/session_manager"
5
+ require_relative "core/project_manager"
6
+ require_relative "core/worktree_manager"
7
+ require_relative "core/rules_manager"
8
+
9
+ module Sxn
10
+ # Core business logic namespace
11
+ module Core
12
+ end
13
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sxn
4
+ module Database
5
+ # Base error class for all database-related errors
6
+ class Error < Sxn::Error; end
7
+
8
+ # Raised when trying to create a session with a name that already exists
9
+ class DuplicateSessionError < Error; end
10
+
11
+ # Raised when trying to access a session that doesn't exist
12
+ class SessionNotFoundError < Error; end
13
+
14
+ # Raised when concurrent updates conflict (optimistic locking)
15
+ class ConflictError < Error; end
16
+
17
+ # Raised when database schema migration fails
18
+ class MigrationError < Error; end
19
+
20
+ # Raised when database integrity checks fail
21
+ class IntegrityError < Error; end
22
+
23
+ # Raised when database connection fails
24
+ class ConnectionError < Error; end
25
+
26
+ # Raised when transaction rollback occurs
27
+ class TransactionError < Error; end
28
+ end
29
+ end