sxn 0.2.3 → 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 +31 -0
- data/Gemfile.lock +1 -1
- 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 +17 -1
- data/lib/sxn/core/config_manager.rb +4 -0
- 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 +38 -11
- 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/sxn.gemspec +85 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 30ceb6fb94a023b31e9b9c4c4354806a5343b7cefa8f1b43047011f86e3048cf
|
|
4
|
+
data.tar.gz: 071b888de74d9e284376acf2ed425a5a33ddf329ac01112bc3fbc65acc8dd363
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 93cb3f85f80ccc13ffcc62662fa86bd0e8b7531105ab08dc49f0213744a858bbaf7fff8823c2d5358617704eeb5aae6f2264ba02f20e14c71003fd8e912136d4
|
|
7
|
+
data.tar.gz: fc1e076616d354218525d2d8ee6a6454a04dfd5d1db969f7032792c91ec1679605a4d0351573a57cd3b5af64c8d06fdecad1068b51dfd66bc2bea5c92bc52dfc
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,36 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.2.5] - 2025-11-30
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- `sxn enter` command to quickly navigate to current session directory
|
|
12
|
+
- `sxn current enter` subcommand as alternative way to enter session
|
|
13
|
+
- `--path` option for `sxn current` to output only the session path
|
|
14
|
+
- `sxn shell` command to install shell integration (idempotent)
|
|
15
|
+
- Auto-detects shell type (bash/zsh)
|
|
16
|
+
- Installs `sxn-enter` function to shell config
|
|
17
|
+
- Supports `--uninstall` to remove integration
|
|
18
|
+
- Supports `--shell-type` to specify shell explicitly
|
|
19
|
+
|
|
20
|
+
## [0.2.4] - 2025-11-30
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
- Interactive worktree wizard after session creation
|
|
24
|
+
- Prompts to add worktrees with descriptive explanations
|
|
25
|
+
- Supports adding multiple worktrees in sequence
|
|
26
|
+
- Explains branch options including remote tracking syntax
|
|
27
|
+
- `--skip-worktree` flag to bypass the wizard when creating sessions
|
|
28
|
+
- `--verbose` flag for worktree debugging with detailed git output
|
|
29
|
+
|
|
30
|
+
### Changed
|
|
31
|
+
- Sessions now automatically switch to newly created session (no need to run `sxn use` afterwards)
|
|
32
|
+
- Improved project manager to safely handle nil projects configuration
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
- Fixed test mocks for verbose parameter in worktree operations
|
|
36
|
+
- Fixed version spec to support semver pre-release format
|
|
37
|
+
|
|
8
38
|
## [0.2.3] - 2025-09-16
|
|
9
39
|
|
|
10
40
|
### Added
|
|
@@ -71,6 +101,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
71
101
|
- Initial placeholder release
|
|
72
102
|
- Basic gem structure
|
|
73
103
|
|
|
104
|
+
[0.2.4]: https://github.com/idl3/sxn/compare/v0.2.3...v0.2.4
|
|
74
105
|
[0.2.3]: https://github.com/idl3/sxn/compare/v0.2.1...v0.2.3
|
|
75
106
|
[0.2.1]: https://github.com/idl3/sxn/compare/v0.2.0...v0.2.1
|
|
76
107
|
[0.2.0]: https://github.com/idl3/sxn/compare/v0.1.0...v0.2.0
|
data/Gemfile.lock
CHANGED
data/lib/sxn/CLI.rb
CHANGED
|
@@ -41,8 +41,11 @@ module Sxn
|
|
|
41
41
|
desc "add SESSION_NAME", "Create a new session (shortcut for 'sxn sessions add')"
|
|
42
42
|
option :description, type: :string, aliases: "-d", desc: "Session description"
|
|
43
43
|
option :linear_task, type: :string, aliases: "-l", desc: "Linear task ID"
|
|
44
|
+
option :branch, type: :string, aliases: "-b", desc: "Default branch for worktrees"
|
|
44
45
|
def add(session_name)
|
|
45
|
-
Commands::Sessions.new
|
|
46
|
+
cmd = Commands::Sessions.new
|
|
47
|
+
cmd.options = options
|
|
48
|
+
cmd.add(session_name)
|
|
46
49
|
rescue Sxn::Error => e
|
|
47
50
|
handle_error(e)
|
|
48
51
|
end
|
|
@@ -62,10 +65,102 @@ module Sxn
|
|
|
62
65
|
handle_error(e)
|
|
63
66
|
end
|
|
64
67
|
|
|
65
|
-
desc "current", "Show current session (shortcut for 'sxn sessions current')"
|
|
68
|
+
desc "current [SUBCOMMAND]", "Show current session (shortcut for 'sxn sessions current')"
|
|
66
69
|
option :verbose, type: :boolean, aliases: "-v", desc: "Show detailed information"
|
|
67
|
-
|
|
68
|
-
|
|
70
|
+
option :path, type: :boolean, aliases: "-p", desc: "Output only the session path"
|
|
71
|
+
def current(subcommand = nil)
|
|
72
|
+
Commands::Sessions.new.current(subcommand)
|
|
73
|
+
rescue Sxn::Error => e
|
|
74
|
+
handle_error(e)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
desc "enter", "Enter current session directory (outputs cd command for shell eval)"
|
|
78
|
+
long_desc <<-LONGDESC
|
|
79
|
+
Outputs a cd command to navigate to the current session directory.
|
|
80
|
+
|
|
81
|
+
Usage with shell eval:
|
|
82
|
+
eval "$(sxn enter)"
|
|
83
|
+
|
|
84
|
+
Or install shell integration for easier use:
|
|
85
|
+
sxn shell
|
|
86
|
+
|
|
87
|
+
Then simply run:
|
|
88
|
+
sxn-enter
|
|
89
|
+
LONGDESC
|
|
90
|
+
def enter
|
|
91
|
+
Commands::Sessions.new.enter
|
|
92
|
+
rescue Sxn::Error => e
|
|
93
|
+
handle_error(e)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
desc "up", "Navigate to project root from session (outputs cd command for shell eval)"
|
|
97
|
+
long_desc <<-LONGDESC
|
|
98
|
+
Outputs a cd command to navigate to the project root from within a session.
|
|
99
|
+
|
|
100
|
+
The project root is determined from the .sxnrc file in the session directory,
|
|
101
|
+
which points back to the parent .sxn folder.
|
|
102
|
+
|
|
103
|
+
Usage with shell eval:
|
|
104
|
+
eval "$(sxn up)"
|
|
105
|
+
|
|
106
|
+
Or install shell integration for easier use:
|
|
107
|
+
sxn shell
|
|
108
|
+
|
|
109
|
+
Then simply run:
|
|
110
|
+
sxn-up
|
|
111
|
+
LONGDESC
|
|
112
|
+
def up
|
|
113
|
+
require "shellwords"
|
|
114
|
+
|
|
115
|
+
session_config = Sxn::Core::SessionConfig.find_from_path(Dir.pwd)
|
|
116
|
+
|
|
117
|
+
unless session_config
|
|
118
|
+
warn "Not in a session directory."
|
|
119
|
+
warn ""
|
|
120
|
+
warn "This command works when run from within a session folder."
|
|
121
|
+
warn "Session folders contain a .sxnrc file that points back to the project."
|
|
122
|
+
warn ""
|
|
123
|
+
warn "Tip: Add this function to your shell profile for easier navigation:"
|
|
124
|
+
warn ""
|
|
125
|
+
warn " sxn-up() { eval \"$(sxn up 2>/dev/null)\" || sxn up; }"
|
|
126
|
+
exit(1)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
project_root = session_config.project_root
|
|
130
|
+
|
|
131
|
+
unless project_root && File.directory?(project_root)
|
|
132
|
+
warn "Could not determine project root from .sxnrc"
|
|
133
|
+
warn "parent_sxn_path: #{session_config.parent_sxn_path || "nil"}"
|
|
134
|
+
exit(1)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Output the cd command for shell integration
|
|
138
|
+
puts "cd #{Shellwords.escape(project_root)}"
|
|
139
|
+
rescue Sxn::Error => e
|
|
140
|
+
handle_error(e)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
desc "shell", "Install shell integration (sxn-enter function)"
|
|
144
|
+
option :shell_type, type: :string, enum: %w[bash zsh auto], default: "auto",
|
|
145
|
+
desc: "Shell type (bash, zsh, or auto-detect)"
|
|
146
|
+
option :uninstall, type: :boolean, default: false, desc: "Remove shell integration"
|
|
147
|
+
long_desc <<-LONGDESC
|
|
148
|
+
Installs shell integration to your shell configuration file (.zshrc or .bashrc).
|
|
149
|
+
|
|
150
|
+
This adds the sxn-enter function which allows you to quickly navigate
|
|
151
|
+
to your current session directory.
|
|
152
|
+
|
|
153
|
+
The installation is idempotent - running it multiple times will not
|
|
154
|
+
add duplicate entries.
|
|
155
|
+
|
|
156
|
+
Examples:
|
|
157
|
+
sxn shell # Auto-detect shell and install
|
|
158
|
+
sxn shell --shell-type=zsh # Install for zsh specifically
|
|
159
|
+
sxn shell --uninstall # Remove shell integration
|
|
160
|
+
LONGDESC
|
|
161
|
+
map "shell" => :install_shell_wrapper
|
|
162
|
+
def install_shell_wrapper
|
|
163
|
+
Commands::Init.new.invoke(:install_shell, [], options)
|
|
69
164
|
rescue Sxn::Error => e
|
|
70
165
|
handle_error(e)
|
|
71
166
|
end
|
data/lib/sxn/commands/init.rb
CHANGED
|
@@ -8,6 +8,34 @@ module Sxn
|
|
|
8
8
|
class Init < Thor
|
|
9
9
|
include Thor::Actions
|
|
10
10
|
|
|
11
|
+
# Shell integration marker - used to identify sxn shell functions
|
|
12
|
+
SHELL_MARKER = "# sxn shell integration"
|
|
13
|
+
SHELL_MARKER_END = "# end sxn shell integration"
|
|
14
|
+
|
|
15
|
+
# Shell function that gets installed
|
|
16
|
+
SHELL_FUNCTION = <<~SHELL.freeze
|
|
17
|
+
#{SHELL_MARKER}
|
|
18
|
+
sxn-enter() {
|
|
19
|
+
local cmd
|
|
20
|
+
cmd="$(sxn enter 2>/dev/null)"
|
|
21
|
+
if [ $? -eq 0 ] && [ -n "$cmd" ]; then
|
|
22
|
+
eval "$cmd"
|
|
23
|
+
else
|
|
24
|
+
sxn enter
|
|
25
|
+
fi
|
|
26
|
+
}
|
|
27
|
+
sxn-up() {
|
|
28
|
+
local cmd
|
|
29
|
+
cmd="$(sxn up 2>/dev/null)"
|
|
30
|
+
if [ $? -eq 0 ] && [ -n "$cmd" ]; then
|
|
31
|
+
eval "$cmd"
|
|
32
|
+
else
|
|
33
|
+
sxn up
|
|
34
|
+
fi
|
|
35
|
+
}
|
|
36
|
+
#{SHELL_MARKER_END}
|
|
37
|
+
SHELL
|
|
38
|
+
|
|
11
39
|
desc "init [FOLDER]", "Initialize sxn in a project folder"
|
|
12
40
|
option :force, type: :boolean, desc: "Force initialization even if already initialized"
|
|
13
41
|
option :auto_detect, type: :boolean, default: true, desc: "Automatically detect and register projects"
|
|
@@ -55,8 +83,116 @@ module Sxn
|
|
|
55
83
|
end
|
|
56
84
|
end
|
|
57
85
|
|
|
86
|
+
desc "install_shell", "Install shell integration (sxn-enter function)"
|
|
87
|
+
option :shell_type, type: :string, enum: %w[bash zsh auto], default: "auto",
|
|
88
|
+
desc: "Shell type (bash, zsh, or auto-detect)"
|
|
89
|
+
option :uninstall, type: :boolean, default: false, desc: "Remove shell integration"
|
|
90
|
+
def install_shell
|
|
91
|
+
@ui.section("Shell Integration")
|
|
92
|
+
|
|
93
|
+
shell_type = detect_shell_type
|
|
94
|
+
rc_file = shell_rc_file(shell_type)
|
|
95
|
+
|
|
96
|
+
unless rc_file
|
|
97
|
+
@ui.error("Could not determine shell configuration file")
|
|
98
|
+
@ui.info("Supported shells: bash, zsh")
|
|
99
|
+
exit(1)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
if options[:uninstall]
|
|
103
|
+
uninstall_shell_integration(rc_file, shell_type)
|
|
104
|
+
else
|
|
105
|
+
install_shell_integration(rc_file, shell_type)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
58
109
|
private
|
|
59
110
|
|
|
111
|
+
def detect_shell_type
|
|
112
|
+
shell_opt = options[:shell_type] || options[:shell] || "auto"
|
|
113
|
+
return shell_opt unless shell_opt == "auto"
|
|
114
|
+
|
|
115
|
+
# Check SHELL environment variable
|
|
116
|
+
current_shell = ENV.fetch("SHELL", "")
|
|
117
|
+
if current_shell.include?("zsh")
|
|
118
|
+
"zsh"
|
|
119
|
+
elsif current_shell.include?("bash")
|
|
120
|
+
"bash"
|
|
121
|
+
else
|
|
122
|
+
# Default to bash
|
|
123
|
+
"bash"
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def shell_rc_file(shell_type)
|
|
128
|
+
home = Dir.home
|
|
129
|
+
case shell_type
|
|
130
|
+
when "zsh"
|
|
131
|
+
File.join(home, ".zshrc")
|
|
132
|
+
when "bash"
|
|
133
|
+
# Prefer .bashrc, fall back to .bash_profile on macOS
|
|
134
|
+
bashrc = File.join(home, ".bashrc")
|
|
135
|
+
bash_profile = File.join(home, ".bash_profile")
|
|
136
|
+
File.exist?(bashrc) ? bashrc : bash_profile
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def shell_integration_installed?(rc_file)
|
|
141
|
+
return false unless File.exist?(rc_file)
|
|
142
|
+
|
|
143
|
+
content = File.read(rc_file)
|
|
144
|
+
content.include?(SHELL_MARKER)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def install_shell_integration(rc_file, _shell_type)
|
|
148
|
+
if shell_integration_installed?(rc_file)
|
|
149
|
+
@ui.info("Shell integration already installed in #{rc_file}")
|
|
150
|
+
@ui.info("Use --uninstall to remove it first if you want to reinstall")
|
|
151
|
+
return
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Ensure rc file exists
|
|
155
|
+
FileUtils.touch(rc_file) unless File.exist?(rc_file)
|
|
156
|
+
|
|
157
|
+
# Append shell function
|
|
158
|
+
File.open(rc_file, "a") do |f|
|
|
159
|
+
f.puts "" # Add blank line before
|
|
160
|
+
f.puts SHELL_FUNCTION
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
@ui.success("Installed shell integration to #{rc_file}")
|
|
164
|
+
@ui.newline
|
|
165
|
+
@ui.info("The following functions were added:")
|
|
166
|
+
@ui.newline
|
|
167
|
+
puts " sxn-enter - Navigate to current session directory"
|
|
168
|
+
puts " sxn-up - Navigate to project root from session"
|
|
169
|
+
@ui.newline
|
|
170
|
+
@ui.recovery_suggestion("Run 'source #{rc_file}' or restart your shell to use them")
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def uninstall_shell_integration(rc_file, _shell_type)
|
|
174
|
+
unless File.exist?(rc_file)
|
|
175
|
+
@ui.info("Shell configuration file not found: #{rc_file}")
|
|
176
|
+
return
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
unless shell_integration_installed?(rc_file)
|
|
180
|
+
@ui.info("Shell integration not installed in #{rc_file}")
|
|
181
|
+
return
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Read file and remove sxn block
|
|
185
|
+
content = File.read(rc_file)
|
|
186
|
+
# Remove the block between markers (including blank line before)
|
|
187
|
+
pattern = /\n?#{Regexp.escape(SHELL_MARKER)}.*?#{Regexp.escape(SHELL_MARKER_END)}\n?/m
|
|
188
|
+
new_content = content.gsub(pattern, "\n")
|
|
189
|
+
|
|
190
|
+
File.write(rc_file, new_content)
|
|
191
|
+
|
|
192
|
+
@ui.success("Removed shell integration from #{rc_file}")
|
|
193
|
+
@ui.recovery_suggestion("Run 'source #{rc_file}' or restart your shell")
|
|
194
|
+
end
|
|
195
|
+
|
|
60
196
|
def determine_sessions_folder(folder)
|
|
61
197
|
return folder if folder && !options[:quiet]
|
|
62
198
|
|
|
@@ -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
|
|
@@ -41,6 +41,7 @@ module Sxn
|
|
|
41
41
|
option :session, type: :string, aliases: "-s", desc: "Target session (defaults to current)"
|
|
42
42
|
option :apply_rules, type: :boolean, default: true, desc: "Apply project rules after creation"
|
|
43
43
|
option :interactive, type: :boolean, aliases: "-i", desc: "Interactive mode"
|
|
44
|
+
option :verbose, type: :boolean, aliases: "-v", desc: "Show detailed output for debugging"
|
|
44
45
|
|
|
45
46
|
def add(project_name = nil, branch = nil)
|
|
46
47
|
ensure_initialized!
|
|
@@ -66,12 +67,16 @@ module Sxn
|
|
|
66
67
|
end
|
|
67
68
|
|
|
68
69
|
begin
|
|
70
|
+
# Enable debug mode if verbose flag is set
|
|
71
|
+
ENV["SXN_DEBUG"] = "true" if options[:verbose]
|
|
72
|
+
|
|
69
73
|
@ui.progress_start("Creating worktree for #{project_name}")
|
|
70
74
|
|
|
71
75
|
worktree = @worktree_manager.add_worktree(
|
|
72
76
|
project_name,
|
|
73
77
|
branch,
|
|
74
|
-
session_name: session_name
|
|
78
|
+
session_name: session_name,
|
|
79
|
+
verbose: options[:verbose]
|
|
75
80
|
)
|
|
76
81
|
|
|
77
82
|
@ui.progress_done
|
|
@@ -84,7 +89,18 @@ module Sxn
|
|
|
84
89
|
rescue Sxn::Error => e
|
|
85
90
|
@ui.progress_failed
|
|
86
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
|
+
|
|
87
101
|
exit(e.exit_code)
|
|
102
|
+
ensure
|
|
103
|
+
ENV.delete("SXN_DEBUG") if options[:verbose]
|
|
88
104
|
end
|
|
89
105
|
end
|
|
90
106
|
|
|
@@ -62,6 +62,10 @@ module Sxn
|
|
|
62
62
|
@sessions_folder || (load_config && @sessions_folder)
|
|
63
63
|
end
|
|
64
64
|
|
|
65
|
+
def sxn_folder_path
|
|
66
|
+
File.dirname(@config_path)
|
|
67
|
+
end
|
|
68
|
+
|
|
65
69
|
def add_project(name, path, type: nil, default_branch: nil)
|
|
66
70
|
config = load_config_file
|
|
67
71
|
config["projects"] ||= {}
|
|
@@ -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"] || {}
|
|
@@ -14,7 +14,7 @@ module Sxn
|
|
|
14
14
|
@project_manager = ProjectManager.new(@config_manager)
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
-
def add_worktree(project_name, branch = nil, session_name: nil)
|
|
17
|
+
def add_worktree(project_name, branch = nil, session_name: nil, verbose: false)
|
|
18
18
|
# Use current session if not specified
|
|
19
19
|
session_name ||= @config_manager.current_session
|
|
20
20
|
raise Sxn::NoActiveSessionError, "No active session. Use 'sxn use <session>' first." unless session_name
|
|
@@ -26,10 +26,12 @@ module Sxn
|
|
|
26
26
|
raise Sxn::ProjectNotFoundError, "Project '#{project_name}' not found" unless project
|
|
27
27
|
|
|
28
28
|
# Determine branch name
|
|
29
|
-
# If no branch specified, use session
|
|
29
|
+
# If no branch specified, use session's default branch from .sxnrc, then fallback to session name
|
|
30
30
|
# If branch starts with "remote:", handle remote branch tracking
|
|
31
31
|
if branch.nil?
|
|
32
|
-
|
|
32
|
+
session_config = SessionConfig.new(session[:path])
|
|
33
|
+
branch = session_config.default_branch if session_config.exists?
|
|
34
|
+
branch ||= session_name
|
|
33
35
|
elsif branch.start_with?("remote:")
|
|
34
36
|
remote_branch = branch.sub("remote:", "")
|
|
35
37
|
# Fetch the remote branch first
|
|
@@ -67,7 +69,7 @@ module Sxn
|
|
|
67
69
|
handle_orphaned_worktree(project[:path], worktree_path)
|
|
68
70
|
|
|
69
71
|
# Create the worktree
|
|
70
|
-
create_git_worktree(project[:path], worktree_path, branch)
|
|
72
|
+
create_git_worktree(project[:path], worktree_path, branch, verbose: verbose)
|
|
71
73
|
|
|
72
74
|
# Register worktree with session
|
|
73
75
|
@session_manager.add_worktree_to_session(session_name, project_name, worktree_path, branch)
|
|
@@ -81,6 +83,11 @@ module Sxn
|
|
|
81
83
|
rescue StandardError => e
|
|
82
84
|
# Clean up on failure
|
|
83
85
|
FileUtils.rm_rf(worktree_path)
|
|
86
|
+
|
|
87
|
+
# If it's already our error with details, re-raise it
|
|
88
|
+
raise e if e.is_a?(Sxn::WorktreeCreationError)
|
|
89
|
+
|
|
90
|
+
# Otherwise wrap it
|
|
84
91
|
raise Sxn::WorktreeCreationError, "Failed to create worktree: #{e.message}"
|
|
85
92
|
end
|
|
86
93
|
end
|
|
@@ -237,7 +244,7 @@ module Sxn
|
|
|
237
244
|
end
|
|
238
245
|
end
|
|
239
246
|
|
|
240
|
-
def create_git_worktree(project_path, worktree_path, branch)
|
|
247
|
+
def create_git_worktree(project_path, worktree_path, branch, verbose: false)
|
|
241
248
|
Dir.chdir(project_path) do
|
|
242
249
|
# Check if branch exists
|
|
243
250
|
branch_exists = system("git show-ref --verify --quiet refs/heads/#{Shellwords.escape(branch)}",
|
|
@@ -274,15 +281,35 @@ module Sxn
|
|
|
274
281
|
error_msg = error_msg.lines.grep(/fatal:/).first&.strip || error_msg
|
|
275
282
|
end
|
|
276
283
|
|
|
277
|
-
|
|
284
|
+
details = []
|
|
285
|
+
details << "Command: #{cmd.join(" ")}"
|
|
286
|
+
details << "Working directory: #{project_path}"
|
|
287
|
+
details << "Target path: #{worktree_path}"
|
|
288
|
+
details << "Branch: #{branch}"
|
|
289
|
+
details << ""
|
|
290
|
+
details << "Git output:"
|
|
291
|
+
details << "STDOUT: #{stdout.strip.empty? ? "(empty)" : stdout}"
|
|
292
|
+
details << "STDERR: #{stderr.strip.empty? ? "(empty)" : stderr}"
|
|
293
|
+
details << "Exit status: #{status.exitstatus}"
|
|
294
|
+
|
|
295
|
+
# Check for common issues
|
|
296
|
+
if !File.directory?(project_path)
|
|
297
|
+
details << "\n⚠️ Project directory does not exist: #{project_path}"
|
|
298
|
+
elsif !File.directory?(File.join(project_path, ".git"))
|
|
299
|
+
details << "\n⚠️ Not a git repository: #{project_path}"
|
|
300
|
+
details << " This might be a git submodule. Try:"
|
|
301
|
+
details << " 1. Ensure the project path points to the submodule directory"
|
|
302
|
+
details << " 2. Check if 'git submodule update --init' has been run"
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
details << "\n⚠️ Target path already exists: #{worktree_path}" if File.exist?(worktree_path)
|
|
306
|
+
|
|
307
|
+
if ENV["SXN_DEBUG"] || verbose
|
|
278
308
|
puts "[DEBUG] Git worktree command failed:"
|
|
279
|
-
puts "
|
|
280
|
-
puts " Directory: #{project_path}"
|
|
281
|
-
puts " STDOUT: #{stdout}"
|
|
282
|
-
puts " STDERR: #{stderr}"
|
|
309
|
+
details.each { |line| puts " #{line}" }
|
|
283
310
|
end
|
|
284
311
|
|
|
285
|
-
raise error_msg
|
|
312
|
+
raise Sxn::WorktreeCreationError.new(error_msg, details: details.join("\n"))
|
|
286
313
|
end
|
|
287
314
|
end
|
|
288
315
|
end
|
data/lib/sxn/core.rb
CHANGED
data/lib/sxn/errors.rb
CHANGED
|
@@ -37,7 +37,16 @@ module Sxn
|
|
|
37
37
|
class WorktreeError < GitError; end
|
|
38
38
|
class WorktreeExistsError < WorktreeError; end
|
|
39
39
|
class WorktreeNotFoundError < WorktreeError; end
|
|
40
|
-
|
|
40
|
+
|
|
41
|
+
class WorktreeCreationError < WorktreeError
|
|
42
|
+
attr_reader :details
|
|
43
|
+
|
|
44
|
+
def initialize(message, details: nil)
|
|
45
|
+
super(message)
|
|
46
|
+
@details = details
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
41
50
|
class WorktreeRemovalError < WorktreeError; end
|
|
42
51
|
class BranchError < GitError; end
|
|
43
52
|
|
data/lib/sxn/ui/prompt.rb
CHANGED
|
@@ -68,6 +68,10 @@ module Sxn
|
|
|
68
68
|
end
|
|
69
69
|
end
|
|
70
70
|
|
|
71
|
+
def default_branch(session_name:)
|
|
72
|
+
branch_name("Default branch for worktrees:", default: session_name)
|
|
73
|
+
end
|
|
74
|
+
|
|
71
75
|
def confirm_deletion(item_name, item_type = "item")
|
|
72
76
|
ask_yes_no("Are you sure you want to delete #{item_type} '#{item_name}'? This action cannot be undone.",
|
|
73
77
|
default: false)
|
data/lib/sxn/version.rb
CHANGED
data/sxn.gemspec
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "lib/sxn/version"
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = "sxn"
|
|
7
|
+
spec.version = Sxn::VERSION
|
|
8
|
+
spec.authors = ["Ernest Sim"]
|
|
9
|
+
spec.email = ["ernest.codes@gmail.com"]
|
|
10
|
+
|
|
11
|
+
spec.summary = "Session management for multi-repository development"
|
|
12
|
+
spec.description = "Sxn simplifies git worktree management with intelligent project rules and secure automation"
|
|
13
|
+
spec.homepage = "https://github.com/idl3/sxn"
|
|
14
|
+
spec.license = "MIT"
|
|
15
|
+
spec.required_ruby_version = ">= 3.2.0"
|
|
16
|
+
|
|
17
|
+
spec.metadata["allowed_push_host"] = "https://rubygems.org"
|
|
18
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
|
19
|
+
spec.metadata["source_code_uri"] = spec.homepage
|
|
20
|
+
spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
|
|
21
|
+
spec.metadata["rubygems_mfa_required"] = "true"
|
|
22
|
+
|
|
23
|
+
# Specify which files should be added to the gem when it is released.
|
|
24
|
+
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
|
25
|
+
spec.files = Dir.chdir(File.expand_path(__dir__)) do
|
|
26
|
+
`git ls-files -z`.split("\x0").reject do |f|
|
|
27
|
+
(f == __FILE__) ||
|
|
28
|
+
f.match(%r{\A(?:(?:test|spec|features)/|\.(?:git|travis|circleci)|appveyor)}) ||
|
|
29
|
+
f.match(/\.db-(?:shm|wal)\z/) || # Exclude SQLite temp files
|
|
30
|
+
f.match(/\.gem\z/) # Exclude gem files
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
spec.bindir = "bin"
|
|
35
|
+
spec.executables = spec.files.grep(%r{\Abin/}) { |f| File.basename(f) }
|
|
36
|
+
spec.require_paths = ["lib"]
|
|
37
|
+
|
|
38
|
+
# Core CLI dependencies
|
|
39
|
+
spec.add_dependency "pastel", "~> 0.8" # Terminal colors
|
|
40
|
+
spec.add_dependency "thor", "~> 1.3" # CLI framework
|
|
41
|
+
spec.add_dependency "tty-progressbar", "~> 0.18" # Progress bars
|
|
42
|
+
spec.add_dependency "tty-prompt", "~> 0.23" # Interactive prompts
|
|
43
|
+
spec.add_dependency "tty-table", "~> 0.12" # Table formatting
|
|
44
|
+
|
|
45
|
+
# Configuration and data management
|
|
46
|
+
spec.add_dependency "dry-configurable", "~> 1.0" # Configuration management
|
|
47
|
+
spec.add_dependency "sqlite3", "~> 1.6" # Session database
|
|
48
|
+
spec.add_dependency "zeitwerk", "~> 2.6" # Code loading
|
|
49
|
+
|
|
50
|
+
# Template engine (secure, sandboxed)
|
|
51
|
+
spec.add_dependency "liquid", "~> 5.4" # Safe template processing
|
|
52
|
+
|
|
53
|
+
# MCP server dependencies
|
|
54
|
+
spec.add_dependency "async", "~> 2.0" # Async operations
|
|
55
|
+
spec.add_dependency "json-schema", "~> 4.0" # Schema validation
|
|
56
|
+
|
|
57
|
+
# Security and encryption
|
|
58
|
+
spec.add_dependency "bcrypt", "~> 3.1" # Password hashing
|
|
59
|
+
spec.add_dependency "openssl", ">= 3.0" # Encryption support
|
|
60
|
+
spec.add_dependency "ostruct" # OpenStruct for Ruby 3.5+ compatibility
|
|
61
|
+
|
|
62
|
+
# File system operations
|
|
63
|
+
spec.add_dependency "listen", "~> 3.8" # File watching for config cache
|
|
64
|
+
spec.add_dependency "parallel", "~> 1.23" # Parallel execution
|
|
65
|
+
|
|
66
|
+
# Development dependencies
|
|
67
|
+
spec.add_development_dependency "aruba", "~> 2.1" # CLI testing
|
|
68
|
+
spec.add_development_dependency "benchmark" # Benchmark for Ruby 3.5+ compatibility
|
|
69
|
+
spec.add_development_dependency "benchmark-ips", "~> 2.12" # Performance benchmarking
|
|
70
|
+
spec.add_development_dependency "bundler", "~> 2.4"
|
|
71
|
+
spec.add_development_dependency "climate_control", "~> 1.2" # Environment variable testing
|
|
72
|
+
spec.add_development_dependency "faker", "~> 3.2" # Test data generation
|
|
73
|
+
spec.add_development_dependency "memory_profiler", "~> 1.0" # Memory profiling
|
|
74
|
+
spec.add_development_dependency "rake", "~> 13.0"
|
|
75
|
+
spec.add_development_dependency "rspec", "~> 3.12"
|
|
76
|
+
spec.add_development_dependency "rubocop", "~> 1.50" # Code linting
|
|
77
|
+
spec.add_development_dependency "rubocop-performance", "~> 1.16"
|
|
78
|
+
spec.add_development_dependency "rubocop-rspec", "~> 2.19"
|
|
79
|
+
spec.add_development_dependency "simplecov", "~> 0.22" # Code coverage
|
|
80
|
+
spec.add_development_dependency "vcr", "~> 6.2" # HTTP interaction recording
|
|
81
|
+
spec.add_development_dependency "webmock", "~> 3.19" # HTTP mocking for MCP tests
|
|
82
|
+
|
|
83
|
+
# For more information and examples about making a new gem, check out our
|
|
84
|
+
# guide at: https://bundler.io/guides/creating_gem.html
|
|
85
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: sxn
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.2.
|
|
4
|
+
version: 0.2.5
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ernest Sim
|
|
@@ -518,6 +518,7 @@ files:
|
|
|
518
518
|
- lib/sxn/core/config_manager.rb
|
|
519
519
|
- lib/sxn/core/project_manager.rb
|
|
520
520
|
- lib/sxn/core/rules_manager.rb
|
|
521
|
+
- lib/sxn/core/session_config.rb
|
|
521
522
|
- lib/sxn/core/session_manager.rb
|
|
522
523
|
- lib/sxn/core/worktree_manager.rb
|
|
523
524
|
- lib/sxn/database.rb
|
|
@@ -610,6 +611,7 @@ files:
|
|
|
610
611
|
- sig/sxn/ui/prompt.rbs
|
|
611
612
|
- sig/sxn/ui/table.rbs
|
|
612
613
|
- sig/sxn/version.rbs
|
|
614
|
+
- sxn.gemspec
|
|
613
615
|
homepage: https://github.com/idl3/sxn
|
|
614
616
|
licenses:
|
|
615
617
|
- MIT
|