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,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
|