sxn 0.2.2 → 0.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +51 -0
- data/Gemfile.lock +1 -1
- data/README.md +76 -8
- data/lib/sxn/CLI.rb +99 -4
- data/lib/sxn/commands/init.rb +136 -0
- data/lib/sxn/commands/sessions.rb +197 -7
- data/lib/sxn/commands/worktrees.rb +40 -5
- data/lib/sxn/core/config_manager.rb +17 -1
- data/lib/sxn/core/project_manager.rb +19 -2
- data/lib/sxn/core/rules_manager.rb +61 -3
- data/lib/sxn/core/session_config.rb +91 -0
- data/lib/sxn/core/session_manager.rb +21 -1
- data/lib/sxn/core/worktree_manager.rb +133 -7
- data/lib/sxn/core.rb +1 -0
- data/lib/sxn/errors.rb +10 -1
- data/lib/sxn/ui/prompt.rb +4 -0
- data/lib/sxn/version.rb +1 -1
- data/script/setup-hooks +52 -0
- data/sxn.gemspec +85 -0
- metadata +4 -1
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "thor"
|
|
4
4
|
require "time"
|
|
5
|
+
require "shellwords"
|
|
5
6
|
|
|
6
7
|
module Sxn
|
|
7
8
|
module Commands
|
|
@@ -16,12 +17,16 @@ module Sxn
|
|
|
16
17
|
@table = Sxn::UI::Table.new
|
|
17
18
|
@config_manager = Sxn::Core::ConfigManager.new
|
|
18
19
|
@session_manager = Sxn::Core::SessionManager.new(@config_manager)
|
|
20
|
+
@project_manager = Sxn::Core::ProjectManager.new(@config_manager)
|
|
21
|
+
@worktree_manager = Sxn::Core::WorktreeManager.new(@config_manager, @session_manager)
|
|
19
22
|
end
|
|
20
23
|
|
|
21
24
|
desc "add NAME", "Create a new session"
|
|
22
25
|
option :description, type: :string, aliases: "-d", desc: "Session description"
|
|
23
26
|
option :linear_task, type: :string, aliases: "-l", desc: "Linear task ID"
|
|
27
|
+
option :branch, type: :string, aliases: "-b", desc: "Default branch for worktrees"
|
|
24
28
|
option :activate, type: :boolean, default: true, desc: "Activate session after creation"
|
|
29
|
+
option :skip_worktree, type: :boolean, default: false, desc: "Skip worktree creation wizard"
|
|
25
30
|
|
|
26
31
|
def add(name = nil)
|
|
27
32
|
ensure_initialized!
|
|
@@ -32,24 +37,31 @@ module Sxn
|
|
|
32
37
|
name = @prompt.session_name(existing_sessions: existing_sessions)
|
|
33
38
|
end
|
|
34
39
|
|
|
40
|
+
# Get default branch - use provided option, or prompt interactively
|
|
41
|
+
default_branch = options[:branch]
|
|
42
|
+
default_branch ||= @prompt.default_branch(session_name: name)
|
|
43
|
+
|
|
35
44
|
begin
|
|
36
45
|
@ui.progress_start("Creating session '#{name}'")
|
|
37
46
|
|
|
38
47
|
session = @session_manager.create_session(
|
|
39
48
|
name,
|
|
40
49
|
description: options[:description],
|
|
41
|
-
linear_task: options[:linear_task]
|
|
50
|
+
linear_task: options[:linear_task],
|
|
51
|
+
default_branch: default_branch
|
|
42
52
|
)
|
|
43
53
|
|
|
44
54
|
@ui.progress_done
|
|
45
55
|
@ui.success("Created session '#{name}'")
|
|
46
56
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
end
|
|
57
|
+
# Always activate the new session (this is the expected behavior)
|
|
58
|
+
@session_manager.use_session(name)
|
|
59
|
+
@ui.success("Switched to session '#{name}'")
|
|
51
60
|
|
|
52
61
|
display_session_info(session)
|
|
62
|
+
|
|
63
|
+
# Offer to add a worktree unless skipped
|
|
64
|
+
offer_worktree_wizard(name) unless options[:skip_worktree]
|
|
53
65
|
rescue Sxn::Error => e
|
|
54
66
|
@ui.progress_failed
|
|
55
67
|
@ui.error(e.message)
|
|
@@ -168,12 +180,23 @@ module Sxn
|
|
|
168
180
|
end
|
|
169
181
|
end
|
|
170
182
|
|
|
171
|
-
desc "current", "Show current session"
|
|
183
|
+
desc "current [SUBCOMMAND]", "Show current session or enter its directory"
|
|
172
184
|
option :verbose, type: :boolean, aliases: "-v", desc: "Show detailed information"
|
|
185
|
+
option :path, type: :boolean, aliases: "-p", desc: "Output only the session path (for shell integration)"
|
|
173
186
|
|
|
174
|
-
def current
|
|
187
|
+
def current(subcommand = nil)
|
|
175
188
|
ensure_initialized!
|
|
176
189
|
|
|
190
|
+
# Handle 'sxn current enter' subcommand
|
|
191
|
+
if subcommand == "enter"
|
|
192
|
+
enter_session
|
|
193
|
+
return
|
|
194
|
+
elsif subcommand
|
|
195
|
+
@ui.error("Unknown subcommand: #{subcommand}")
|
|
196
|
+
@ui.info("Available: enter")
|
|
197
|
+
exit(1)
|
|
198
|
+
end
|
|
199
|
+
|
|
177
200
|
begin
|
|
178
201
|
session = @session_manager.current_session
|
|
179
202
|
|
|
@@ -183,6 +206,12 @@ module Sxn
|
|
|
183
206
|
return
|
|
184
207
|
end
|
|
185
208
|
|
|
209
|
+
# If --path flag, just output the path for shell integration
|
|
210
|
+
if options[:path]
|
|
211
|
+
puts session[:path]
|
|
212
|
+
return
|
|
213
|
+
end
|
|
214
|
+
|
|
186
215
|
@ui.section("Current Session")
|
|
187
216
|
display_session_info(session, verbose: options[:verbose])
|
|
188
217
|
rescue Sxn::Error => e
|
|
@@ -191,6 +220,11 @@ module Sxn
|
|
|
191
220
|
end
|
|
192
221
|
end
|
|
193
222
|
|
|
223
|
+
desc "enter", "Enter the current session directory (outputs cd command)"
|
|
224
|
+
def enter
|
|
225
|
+
enter_session
|
|
226
|
+
end
|
|
227
|
+
|
|
194
228
|
desc "archive NAME", "Archive a session"
|
|
195
229
|
def archive(name = nil)
|
|
196
230
|
ensure_initialized!
|
|
@@ -256,6 +290,7 @@ module Sxn
|
|
|
256
290
|
@ui.key_value("Name", session[:name] || "Unknown")
|
|
257
291
|
@ui.key_value("Status", (session[:status] || "unknown").capitalize)
|
|
258
292
|
@ui.key_value("Path", session[:path] || "Unknown")
|
|
293
|
+
@ui.key_value("Default Branch", session[:default_branch]) if session[:default_branch]
|
|
259
294
|
|
|
260
295
|
@ui.key_value("Description", session[:description]) if session[:description]
|
|
261
296
|
|
|
@@ -310,6 +345,161 @@ module Sxn
|
|
|
310
345
|
rescue ArgumentError
|
|
311
346
|
timestamp # Return original if parsing fails
|
|
312
347
|
end
|
|
348
|
+
|
|
349
|
+
def offer_worktree_wizard(session_name)
|
|
350
|
+
@ui.newline
|
|
351
|
+
@ui.section("Add Worktree")
|
|
352
|
+
|
|
353
|
+
# Check if there are any projects configured
|
|
354
|
+
projects = @project_manager.list_projects
|
|
355
|
+
if projects.empty?
|
|
356
|
+
@ui.info("No projects configured yet.")
|
|
357
|
+
@ui.recovery_suggestion("Add projects with 'sxn projects add <name> <path>'")
|
|
358
|
+
return
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# Ask if user wants to add a worktree
|
|
362
|
+
return unless @prompt.ask_yes_no("Would you like to add a worktree to this session?", default: true)
|
|
363
|
+
|
|
364
|
+
# Step 1: Select project with descriptions
|
|
365
|
+
@ui.newline
|
|
366
|
+
@ui.info("A worktree is a linked copy of a project that allows you to work on a branch")
|
|
367
|
+
@ui.info("without affecting the main repository. Select which project to add:")
|
|
368
|
+
@ui.newline
|
|
369
|
+
|
|
370
|
+
project_choices = projects.map do |p|
|
|
371
|
+
{
|
|
372
|
+
name: "#{p[:name]} (#{p[:type]}) - #{p[:path]}",
|
|
373
|
+
value: p[:name]
|
|
374
|
+
}
|
|
375
|
+
end
|
|
376
|
+
project_name = @prompt.select("Select project:", project_choices)
|
|
377
|
+
|
|
378
|
+
# Step 2: Get branch name with explanation
|
|
379
|
+
# Get session's default branch for the default value
|
|
380
|
+
session = @session_manager.get_session(session_name)
|
|
381
|
+
default_branch_for_worktree = session[:default_branch] || session_name
|
|
382
|
+
|
|
383
|
+
@ui.newline
|
|
384
|
+
@ui.info("Enter the branch name for this worktree.")
|
|
385
|
+
@ui.info("This can be an existing branch or a new one to create.")
|
|
386
|
+
@ui.info("Tip: Use 'remote:<branch>' to track a remote branch (e.g., 'remote:origin/main')")
|
|
387
|
+
@ui.newline
|
|
388
|
+
|
|
389
|
+
branch = @prompt.branch_name(
|
|
390
|
+
"Branch name:",
|
|
391
|
+
default: default_branch_for_worktree
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
# Step 3: Create the worktree
|
|
395
|
+
create_worktree_for_session(project_name, branch, session_name)
|
|
396
|
+
|
|
397
|
+
# Offer to add more worktrees
|
|
398
|
+
add_more_worktrees(session_name)
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def create_worktree_for_session(project_name, branch, session_name)
|
|
402
|
+
@ui.newline
|
|
403
|
+
@ui.progress_start("Creating worktree for #{project_name}")
|
|
404
|
+
|
|
405
|
+
worktree = @worktree_manager.add_worktree(
|
|
406
|
+
project_name,
|
|
407
|
+
branch,
|
|
408
|
+
session_name: session_name
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
@ui.progress_done
|
|
412
|
+
@ui.success("Created worktree for #{project_name}")
|
|
413
|
+
|
|
414
|
+
display_worktree_info(worktree)
|
|
415
|
+
|
|
416
|
+
# Apply rules
|
|
417
|
+
apply_project_rules(project_name, session_name)
|
|
418
|
+
rescue Sxn::Error => e
|
|
419
|
+
@ui.progress_failed
|
|
420
|
+
@ui.error(e.message)
|
|
421
|
+
@ui.recovery_suggestion("You can try again with 'sxn worktree add #{project_name}'")
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
def add_more_worktrees(session_name)
|
|
425
|
+
projects = @project_manager.list_projects
|
|
426
|
+
return if projects.empty?
|
|
427
|
+
|
|
428
|
+
# Get session's default branch for the default value
|
|
429
|
+
session = @session_manager.get_session(session_name)
|
|
430
|
+
default_branch_for_worktree = session[:default_branch] || session_name
|
|
431
|
+
|
|
432
|
+
while @prompt.ask_yes_no("Would you like to add another worktree?", default: false)
|
|
433
|
+
@ui.newline
|
|
434
|
+
|
|
435
|
+
project_choices = projects.map do |p|
|
|
436
|
+
{
|
|
437
|
+
name: "#{p[:name]} (#{p[:type]}) - #{p[:path]}",
|
|
438
|
+
value: p[:name]
|
|
439
|
+
}
|
|
440
|
+
end
|
|
441
|
+
project_name = @prompt.select("Select project:", project_choices)
|
|
442
|
+
|
|
443
|
+
branch = @prompt.branch_name(
|
|
444
|
+
"Branch name:",
|
|
445
|
+
default: default_branch_for_worktree
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
create_worktree_for_session(project_name, branch, session_name)
|
|
449
|
+
end
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
def display_worktree_info(worktree)
|
|
453
|
+
@ui.newline
|
|
454
|
+
@ui.key_value("Project", worktree[:project])
|
|
455
|
+
@ui.key_value("Branch", worktree[:branch])
|
|
456
|
+
@ui.key_value("Path", worktree[:path])
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
def enter_session
|
|
460
|
+
session = @session_manager.current_session
|
|
461
|
+
|
|
462
|
+
if session.nil?
|
|
463
|
+
# When no session, provide helpful guidance instead of just a cd command
|
|
464
|
+
warn "No active session. Use 'sxn use <session>' to activate a session first."
|
|
465
|
+
warn ""
|
|
466
|
+
warn "Tip: Add this function to your shell profile for easier navigation:"
|
|
467
|
+
warn ""
|
|
468
|
+
warn " sxn-enter() { eval \"$(sxn enter 2>/dev/null)\" || sxn enter; }"
|
|
469
|
+
warn ""
|
|
470
|
+
exit(1)
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
session_path = session[:path]
|
|
474
|
+
|
|
475
|
+
unless session_path && File.directory?(session_path)
|
|
476
|
+
warn "Session directory does not exist: #{session_path || "nil"}"
|
|
477
|
+
exit(1)
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
# Output the cd command for shell integration
|
|
481
|
+
# Users can use: eval "$(sxn enter)" or the sxn-enter shell function
|
|
482
|
+
puts "cd #{Shellwords.escape(session_path)}"
|
|
483
|
+
rescue Sxn::Error => e
|
|
484
|
+
warn e.message
|
|
485
|
+
exit(e.exit_code)
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
def apply_project_rules(project_name, session_name)
|
|
489
|
+
rules_manager = Sxn::Core::RulesManager.new(@config_manager, @project_manager)
|
|
490
|
+
|
|
491
|
+
@ui.progress_start("Applying rules for #{project_name}")
|
|
492
|
+
results = rules_manager.apply_rules(project_name, session_name)
|
|
493
|
+
@ui.progress_done
|
|
494
|
+
|
|
495
|
+
if results[:success]
|
|
496
|
+
@ui.success("Applied #{results[:applied_count]} rules") if results[:applied_count].positive?
|
|
497
|
+
else
|
|
498
|
+
@ui.warning("Some rules failed to apply")
|
|
499
|
+
end
|
|
500
|
+
rescue StandardError => e
|
|
501
|
+
@ui.warning("Could not apply rules: #{e.message}")
|
|
502
|
+
end
|
|
313
503
|
end
|
|
314
504
|
end
|
|
315
505
|
end
|
|
@@ -19,10 +19,29 @@ module Sxn
|
|
|
19
19
|
@worktree_manager = Sxn::Core::WorktreeManager.new(@config_manager, @session_manager)
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
-
desc "add PROJECT [BRANCH]", "Add worktree to current session"
|
|
22
|
+
desc "add PROJECT [BRANCH]", "Add worktree to current session (defaults branch to session name)"
|
|
23
|
+
long_desc <<-DESC
|
|
24
|
+
Add a worktree for a project to the current session.
|
|
25
|
+
|
|
26
|
+
Branch options:
|
|
27
|
+
- No branch specified: Uses the session name as the branch name
|
|
28
|
+
- Branch name: Creates or checks out the specified branch
|
|
29
|
+
- remote:<branch>: Fetches and tracks the remote branch
|
|
30
|
+
|
|
31
|
+
Examples:
|
|
32
|
+
- sxn worktree add atlas-core
|
|
33
|
+
Creates worktree with branch name matching current session
|
|
34
|
+
|
|
35
|
+
- sxn worktree add atlas-core feature-branch
|
|
36
|
+
Creates worktree with specified branch name
|
|
37
|
+
|
|
38
|
+
- sxn worktree add atlas-core remote:origin/main
|
|
39
|
+
Fetches and tracks the remote branch
|
|
40
|
+
DESC
|
|
23
41
|
option :session, type: :string, aliases: "-s", desc: "Target session (defaults to current)"
|
|
24
42
|
option :apply_rules, type: :boolean, default: true, desc: "Apply project rules after creation"
|
|
25
43
|
option :interactive, type: :boolean, aliases: "-i", desc: "Interactive mode"
|
|
44
|
+
option :verbose, type: :boolean, aliases: "-v", desc: "Show detailed output for debugging"
|
|
26
45
|
|
|
27
46
|
def add(project_name = nil, branch = nil)
|
|
28
47
|
ensure_initialized!
|
|
@@ -33,10 +52,11 @@ module Sxn
|
|
|
33
52
|
return if project_name.nil?
|
|
34
53
|
end
|
|
35
54
|
|
|
36
|
-
# Interactive branch selection if not provided
|
|
55
|
+
# Interactive branch selection if not provided and interactive mode
|
|
56
|
+
# Note: If branch is nil, WorktreeManager will use session name as default
|
|
37
57
|
if options[:interactive] && branch.nil?
|
|
38
|
-
|
|
39
|
-
branch = @prompt.branch_name("Enter branch name:", default:
|
|
58
|
+
session_name = options[:session] || @config_manager.current_session
|
|
59
|
+
branch = @prompt.branch_name("Enter branch name:", default: session_name)
|
|
40
60
|
end
|
|
41
61
|
|
|
42
62
|
session_name = options[:session] || @config_manager.current_session
|
|
@@ -47,12 +67,16 @@ module Sxn
|
|
|
47
67
|
end
|
|
48
68
|
|
|
49
69
|
begin
|
|
70
|
+
# Enable debug mode if verbose flag is set
|
|
71
|
+
ENV["SXN_DEBUG"] = "true" if options[:verbose]
|
|
72
|
+
|
|
50
73
|
@ui.progress_start("Creating worktree for #{project_name}")
|
|
51
74
|
|
|
52
75
|
worktree = @worktree_manager.add_worktree(
|
|
53
76
|
project_name,
|
|
54
77
|
branch,
|
|
55
|
-
session_name: session_name
|
|
78
|
+
session_name: session_name,
|
|
79
|
+
verbose: options[:verbose]
|
|
56
80
|
)
|
|
57
81
|
|
|
58
82
|
@ui.progress_done
|
|
@@ -65,7 +89,18 @@ module Sxn
|
|
|
65
89
|
rescue Sxn::Error => e
|
|
66
90
|
@ui.progress_failed
|
|
67
91
|
@ui.error(e.message)
|
|
92
|
+
|
|
93
|
+
if options[:verbose] && e.respond_to?(:details)
|
|
94
|
+
@ui.newline
|
|
95
|
+
@ui.subsection("Debug Information")
|
|
96
|
+
@ui.info(e.details)
|
|
97
|
+
elsif !options[:verbose]
|
|
98
|
+
@ui.recovery_suggestion("Run with --verbose flag for more details")
|
|
99
|
+
end
|
|
100
|
+
|
|
68
101
|
exit(e.exit_code)
|
|
102
|
+
ensure
|
|
103
|
+
ENV.delete("SXN_DEBUG") if options[:verbose]
|
|
69
104
|
end
|
|
70
105
|
end
|
|
71
106
|
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "fileutils"
|
|
4
4
|
require "yaml"
|
|
5
5
|
require "pathname"
|
|
6
|
+
require "ostruct"
|
|
6
7
|
|
|
7
8
|
module Sxn
|
|
8
9
|
module Core
|
|
@@ -40,7 +41,10 @@ module Sxn
|
|
|
40
41
|
raise Sxn::ConfigurationError, "Project not initialized. Run 'sxn init' first." unless initialized?
|
|
41
42
|
|
|
42
43
|
discovery = Sxn::Config::ConfigDiscovery.new(@base_path)
|
|
43
|
-
discovery.discover_config
|
|
44
|
+
config_hash = discovery.discover_config
|
|
45
|
+
|
|
46
|
+
# Convert nested hashes to OpenStruct recursively
|
|
47
|
+
config_to_struct(config_hash)
|
|
44
48
|
end
|
|
45
49
|
|
|
46
50
|
def update_current_session(session_name)
|
|
@@ -58,6 +62,10 @@ module Sxn
|
|
|
58
62
|
@sessions_folder || (load_config && @sessions_folder)
|
|
59
63
|
end
|
|
60
64
|
|
|
65
|
+
def sxn_folder_path
|
|
66
|
+
File.dirname(@config_path)
|
|
67
|
+
end
|
|
68
|
+
|
|
61
69
|
def add_project(name, path, type: nil, default_branch: nil)
|
|
62
70
|
config = load_config_file
|
|
63
71
|
config["projects"] ||= {}
|
|
@@ -194,6 +202,14 @@ module Sxn
|
|
|
194
202
|
|
|
195
203
|
private
|
|
196
204
|
|
|
205
|
+
def config_to_struct(obj)
|
|
206
|
+
return obj unless obj.is_a?(Hash)
|
|
207
|
+
|
|
208
|
+
OpenStruct.new(
|
|
209
|
+
obj.transform_values { |v| config_to_struct(v) }
|
|
210
|
+
)
|
|
211
|
+
end
|
|
212
|
+
|
|
197
213
|
def sessions_folder_relative_path
|
|
198
214
|
return ".sxn" unless @sessions_folder
|
|
199
215
|
|
|
@@ -193,9 +193,26 @@ module Sxn
|
|
|
193
193
|
|
|
194
194
|
# Get project-specific rules from config
|
|
195
195
|
config = @config_manager.get_config
|
|
196
|
-
project_config = config.projects[name]
|
|
197
196
|
|
|
198
|
-
|
|
197
|
+
# Handle both OpenStruct and Hash for projects config
|
|
198
|
+
projects = config.projects
|
|
199
|
+
project_config = if projects.is_a?(OpenStruct)
|
|
200
|
+
projects.to_h[name.to_sym] || projects.to_h[name]
|
|
201
|
+
elsif projects.is_a?(Hash)
|
|
202
|
+
projects[name]
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Extract rules, handling both OpenStruct and Hash
|
|
206
|
+
rules = if project_config.is_a?(OpenStruct)
|
|
207
|
+
project_config.to_h[:rules] || project_config.to_h["rules"] || {}
|
|
208
|
+
elsif project_config.is_a?(Hash)
|
|
209
|
+
project_config["rules"] || project_config[:rules] || {}
|
|
210
|
+
else
|
|
211
|
+
{}
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Convert OpenStruct rules to hash if needed
|
|
215
|
+
rules = rules.to_h if rules.is_a?(OpenStruct)
|
|
199
216
|
|
|
200
217
|
# Add default rules based on project type
|
|
201
218
|
default_rules = get_default_rules_for_type(project[:type])
|
|
@@ -87,11 +87,69 @@ module Sxn
|
|
|
87
87
|
"No worktree found for project '#{project_name}' in session '#{session_name}'"
|
|
88
88
|
end
|
|
89
89
|
|
|
90
|
-
# Get project rules
|
|
90
|
+
# Get project rules (format: { "copy_files" => [...], "setup_commands" => [...] })
|
|
91
91
|
rules = @project_manager.get_project_rules(project_name)
|
|
92
92
|
|
|
93
|
-
#
|
|
94
|
-
|
|
93
|
+
# Transform rules to RulesEngine format and apply
|
|
94
|
+
apply_rules_to_worktree(project, worktree, rules)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def apply_rules_to_worktree(project, worktree, rules)
|
|
98
|
+
project_path = project[:path]
|
|
99
|
+
worktree_path = worktree[:path]
|
|
100
|
+
|
|
101
|
+
# Ensure paths exist
|
|
102
|
+
raise Sxn::InvalidProjectPathError, "Project path does not exist: #{project_path}" unless File.directory?(project_path)
|
|
103
|
+
raise Sxn::WorktreeNotFoundError, "Worktree path does not exist: #{worktree_path}" unless File.directory?(worktree_path)
|
|
104
|
+
|
|
105
|
+
applied_count = 0
|
|
106
|
+
errors = []
|
|
107
|
+
|
|
108
|
+
# Apply copy_files rules
|
|
109
|
+
rules["copy_files"]&.each do |rule_config|
|
|
110
|
+
apply_copy_file_rule(project_path, worktree_path, rule_config)
|
|
111
|
+
applied_count += 1
|
|
112
|
+
rescue StandardError => e
|
|
113
|
+
errors << "copy_files: #{e.message}"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Apply setup_commands rules (skip for now as they can be slow)
|
|
117
|
+
# Users can run these manually if needed
|
|
118
|
+
|
|
119
|
+
{
|
|
120
|
+
success: errors.empty?,
|
|
121
|
+
applied_count: applied_count,
|
|
122
|
+
errors: errors
|
|
123
|
+
}
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def apply_copy_file_rule(project_path, worktree_path, rule_config)
|
|
127
|
+
source_pattern = rule_config["source"]
|
|
128
|
+
strategy = rule_config["strategy"] || "copy"
|
|
129
|
+
|
|
130
|
+
# Handle glob patterns
|
|
131
|
+
source_files = if source_pattern.include?("*")
|
|
132
|
+
Dir.glob(File.join(project_path, source_pattern))
|
|
133
|
+
else
|
|
134
|
+
single_file = File.join(project_path, source_pattern)
|
|
135
|
+
File.exist?(single_file) ? [single_file] : []
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
source_files.each do |file_path|
|
|
139
|
+
# Calculate relative path from project root
|
|
140
|
+
relative_path = file_path.sub("#{project_path}/", "")
|
|
141
|
+
dest_file = File.join(worktree_path, relative_path)
|
|
142
|
+
|
|
143
|
+
# Create destination directory if needed
|
|
144
|
+
FileUtils.mkdir_p(File.dirname(dest_file))
|
|
145
|
+
|
|
146
|
+
case strategy
|
|
147
|
+
when "copy"
|
|
148
|
+
FileUtils.cp(file_path, dest_file)
|
|
149
|
+
when "symlink"
|
|
150
|
+
FileUtils.ln_sf(file_path, dest_file)
|
|
151
|
+
end
|
|
152
|
+
end
|
|
95
153
|
end
|
|
96
154
|
|
|
97
155
|
def validate_rules(project_name)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Sxn
|
|
6
|
+
module Core
|
|
7
|
+
# Manages .sxnrc session configuration files
|
|
8
|
+
class SessionConfig
|
|
9
|
+
FILENAME = ".sxnrc"
|
|
10
|
+
|
|
11
|
+
attr_reader :session_path, :config_path
|
|
12
|
+
|
|
13
|
+
def initialize(session_path)
|
|
14
|
+
@session_path = session_path
|
|
15
|
+
@config_path = File.join(session_path, FILENAME)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def create(parent_sxn_path:, default_branch:, session_name:)
|
|
19
|
+
config = {
|
|
20
|
+
"version" => 1,
|
|
21
|
+
"parent_sxn_path" => parent_sxn_path,
|
|
22
|
+
"default_branch" => default_branch,
|
|
23
|
+
"session_name" => session_name,
|
|
24
|
+
"created_at" => Time.now.iso8601
|
|
25
|
+
}
|
|
26
|
+
File.write(@config_path, YAML.dump(config))
|
|
27
|
+
config
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def exists?
|
|
31
|
+
File.exist?(@config_path)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def read
|
|
35
|
+
return nil unless exists?
|
|
36
|
+
|
|
37
|
+
YAML.safe_load_file(@config_path) || {}
|
|
38
|
+
rescue Psych::SyntaxError
|
|
39
|
+
nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def parent_sxn_path
|
|
43
|
+
read&.dig("parent_sxn_path")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def default_branch
|
|
47
|
+
read&.dig("default_branch")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def session_name
|
|
51
|
+
read&.dig("session_name")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def project_root
|
|
55
|
+
parent_path = parent_sxn_path
|
|
56
|
+
return nil unless parent_path
|
|
57
|
+
|
|
58
|
+
# parent_sxn_path points to .sxn folder, project root is its parent
|
|
59
|
+
File.dirname(parent_path)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def update(updates)
|
|
63
|
+
config = read || {}
|
|
64
|
+
updates.each do |key, value|
|
|
65
|
+
config[key.to_s] = value
|
|
66
|
+
end
|
|
67
|
+
File.write(@config_path, YAML.dump(config))
|
|
68
|
+
config
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Class method: Find .sxnrc by walking up directory tree
|
|
72
|
+
def self.find_from_path(start_path)
|
|
73
|
+
current = File.expand_path(start_path)
|
|
74
|
+
|
|
75
|
+
while current != "/" && current != File.dirname(current)
|
|
76
|
+
config_path = File.join(current, FILENAME)
|
|
77
|
+
return new(current) if File.exist?(config_path)
|
|
78
|
+
|
|
79
|
+
current = File.dirname(current)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
nil
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Class method: Check if path is within a session
|
|
86
|
+
def self.in_session?(path = Dir.pwd)
|
|
87
|
+
!find_from_path(path).nil?
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -13,7 +13,7 @@ module Sxn
|
|
|
13
13
|
@database = initialize_database
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
-
def create_session(name, description: nil, linear_task: nil)
|
|
16
|
+
def create_session(name, description: nil, linear_task: nil, default_branch: nil)
|
|
17
17
|
validate_session_name!(name)
|
|
18
18
|
ensure_sessions_folder_exists!
|
|
19
19
|
|
|
@@ -21,10 +21,19 @@ module Sxn
|
|
|
21
21
|
|
|
22
22
|
session_id = SecureRandom.uuid
|
|
23
23
|
session_path = File.join(@config_manager.sessions_folder_path, name)
|
|
24
|
+
branch = default_branch || name
|
|
24
25
|
|
|
25
26
|
# Create session directory
|
|
26
27
|
FileUtils.mkdir_p(session_path)
|
|
27
28
|
|
|
29
|
+
# Create .sxnrc configuration file
|
|
30
|
+
session_config = SessionConfig.new(session_path)
|
|
31
|
+
session_config.create(
|
|
32
|
+
parent_sxn_path: @config_manager.sxn_folder_path,
|
|
33
|
+
default_branch: branch,
|
|
34
|
+
session_name: name
|
|
35
|
+
)
|
|
36
|
+
|
|
28
37
|
# Create session record
|
|
29
38
|
session_data = {
|
|
30
39
|
id: session_id,
|
|
@@ -35,6 +44,7 @@ module Sxn
|
|
|
35
44
|
status: "active",
|
|
36
45
|
description: description,
|
|
37
46
|
linear_task: linear_task,
|
|
47
|
+
default_branch: branch,
|
|
38
48
|
projects: [],
|
|
39
49
|
worktrees: {}
|
|
40
50
|
}
|
|
@@ -162,6 +172,15 @@ module Sxn
|
|
|
162
172
|
session_data[:worktrees] || {}
|
|
163
173
|
end
|
|
164
174
|
|
|
175
|
+
def get_session_default_branch(session_name)
|
|
176
|
+
session = get_session(session_name)
|
|
177
|
+
return nil unless session
|
|
178
|
+
|
|
179
|
+
# Read from .sxnrc file in session directory
|
|
180
|
+
session_config = SessionConfig.new(session[:path])
|
|
181
|
+
session_config.default_branch
|
|
182
|
+
end
|
|
183
|
+
|
|
165
184
|
def archive_session(name)
|
|
166
185
|
update_session_status_by_name(name, "archived")
|
|
167
186
|
true
|
|
@@ -224,6 +243,7 @@ module Sxn
|
|
|
224
243
|
status: db_row[:status],
|
|
225
244
|
description: metadata["description"] || db_row[:description],
|
|
226
245
|
linear_task: metadata["linear_task"] || db_row[:linear_task],
|
|
246
|
+
default_branch: metadata["default_branch"] || db_row[:default_branch],
|
|
227
247
|
# Support both metadata and database columns for backward compatibility
|
|
228
248
|
projects: db_row[:projects] || metadata["projects"] || [],
|
|
229
249
|
worktrees: db_row[:worktrees] || metadata["worktrees"] || {}
|