snakommit 0.1.1
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/.gitignore +104 -0
- data/CHANGELOG.md +55 -0
- data/Gemfile +15 -0
- data/Gemfile.lock +81 -0
- data/LICENSE +21 -0
- data/README.md +275 -0
- data/Rakefile +58 -0
- data/bin/sk +9 -0
- data/bin/snakommit +10 -0
- data/lib/snakommit/cli.rb +371 -0
- data/lib/snakommit/config.rb +154 -0
- data/lib/snakommit/git.rb +212 -0
- data/lib/snakommit/hooks.rb +258 -0
- data/lib/snakommit/performance.rb +328 -0
- data/lib/snakommit/prompt.rb +472 -0
- data/lib/snakommit/templates.rb +146 -0
- data/lib/snakommit/version.rb +5 -0
- data/lib/snakommit.rb +35 -0
- data/snakommit.gemspec +38 -0
- metadata +194 -0
@@ -0,0 +1,212 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'git'
|
4
|
+
require 'open3'
|
5
|
+
require 'json'
|
6
|
+
require 'fileutils'
|
7
|
+
require 'singleton'
|
8
|
+
|
9
|
+
module Snakommit
|
10
|
+
# Git operations handler
|
11
|
+
class Git
|
12
|
+
class GitError < StandardError; end
|
13
|
+
|
14
|
+
# Process pool for executing Git commands
|
15
|
+
class CommandPool
|
16
|
+
include Singleton
|
17
|
+
|
18
|
+
def initialize
|
19
|
+
@mutex = Mutex.new
|
20
|
+
@command_cache = {}
|
21
|
+
end
|
22
|
+
|
23
|
+
# Execute a command with caching for identical commands
|
24
|
+
# @param command [String] Command to execute
|
25
|
+
# @return [String] Command output
|
26
|
+
def execute(command)
|
27
|
+
# Return from cache if available and recent
|
28
|
+
@mutex.synchronize do
|
29
|
+
if @command_cache[command] && (Time.now - @command_cache[command][:timestamp] < 1)
|
30
|
+
return @command_cache[command][:output]
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
# Execute the command
|
35
|
+
stdout, stderr, status = Open3.capture3(command)
|
36
|
+
|
37
|
+
unless status.success?
|
38
|
+
raise GitError, "Git command failed: #{stderr.strip}"
|
39
|
+
end
|
40
|
+
|
41
|
+
result = stdout.strip
|
42
|
+
|
43
|
+
# Cache the result
|
44
|
+
@mutex.synchronize do
|
45
|
+
@command_cache[command] = { output: result, timestamp: Time.now }
|
46
|
+
|
47
|
+
# Clean cache if it gets too large
|
48
|
+
if @command_cache.size > 100
|
49
|
+
@command_cache.keys.sort_by { |k| @command_cache[k][:timestamp] }[0...50].each do |k|
|
50
|
+
@command_cache.delete(k)
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
result
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
SELECTION_FILE = File.join(Snakommit.config_dir, 'selections.json')
|
60
|
+
SELECTION_TTL = 1800 # 30 minutes in seconds
|
61
|
+
CACHE_TTL = 5 # 5 seconds for git status cache
|
62
|
+
|
63
|
+
def self.in_repo?
|
64
|
+
system('git rev-parse --is-inside-work-tree >/dev/null 2>&1')
|
65
|
+
end
|
66
|
+
|
67
|
+
def initialize
|
68
|
+
@path = Dir.pwd
|
69
|
+
validate_git_repo
|
70
|
+
# Initialize cache for performance
|
71
|
+
@cache = Performance::Cache.new(50, CACHE_TTL)
|
72
|
+
rescue Errno::ENOENT, ArgumentError => e
|
73
|
+
raise GitError, "Failed to initialize Git: #{e.message}"
|
74
|
+
end
|
75
|
+
|
76
|
+
def staged_files
|
77
|
+
@cache.get(:staged_files) || @cache.set(:staged_files, run_command("git diff --name-only --cached").split("\n"))
|
78
|
+
end
|
79
|
+
|
80
|
+
def unstaged_files
|
81
|
+
@cache.get(:unstaged_files) || @cache.set(:unstaged_files, run_command("git diff --name-only").split("\n"))
|
82
|
+
end
|
83
|
+
|
84
|
+
def untracked_files
|
85
|
+
@cache.get(:untracked_files) || @cache.set(:untracked_files, run_command("git ls-files --others --exclude-standard").split("\n"))
|
86
|
+
end
|
87
|
+
|
88
|
+
def add(file)
|
89
|
+
result = run_command("git add -- #{shell_escape(file)}")
|
90
|
+
# Invalidate cache since repository state changed
|
91
|
+
invalidate_status_cache
|
92
|
+
result
|
93
|
+
end
|
94
|
+
|
95
|
+
def reset(file)
|
96
|
+
result = run_command("git reset HEAD -- #{shell_escape(file)}")
|
97
|
+
# Invalidate cache since repository state changed
|
98
|
+
invalidate_status_cache
|
99
|
+
result
|
100
|
+
end
|
101
|
+
|
102
|
+
# Commit with the given message
|
103
|
+
def commit(message)
|
104
|
+
with_temp_file(message) do |message_file|
|
105
|
+
stdout, stderr, status = Open3.capture3('git', 'commit', '-F', message_file)
|
106
|
+
|
107
|
+
# Clear any saved selections after successful commit
|
108
|
+
clear_saved_selections
|
109
|
+
|
110
|
+
unless status.success?
|
111
|
+
raise GitError, "Failed to commit: #{stderr.strip}"
|
112
|
+
end
|
113
|
+
|
114
|
+
# Invalidate cache since repository state changed
|
115
|
+
invalidate_status_cache
|
116
|
+
true
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
120
|
+
# Save selected files to a temporary file
|
121
|
+
def save_selections(selected_files)
|
122
|
+
return if selected_files.nil? || selected_files.empty?
|
123
|
+
|
124
|
+
repo_path = run_command("git rev-parse --show-toplevel").strip
|
125
|
+
data = {
|
126
|
+
repo: repo_path,
|
127
|
+
selected: selected_files,
|
128
|
+
timestamp: Time.now.to_i
|
129
|
+
}
|
130
|
+
|
131
|
+
FileUtils.mkdir_p(File.dirname(SELECTION_FILE))
|
132
|
+
File.write(SELECTION_FILE, data.to_json)
|
133
|
+
rescue => e
|
134
|
+
# Silently fail, this is just a convenience feature
|
135
|
+
warn "Warning: Failed to save selections: #{e.message}" if ENV['SNAKOMMIT_DEBUG']
|
136
|
+
end
|
137
|
+
|
138
|
+
# Get previously selected files if they exist and are recent
|
139
|
+
def get_saved_selections
|
140
|
+
return nil unless File.exist?(SELECTION_FILE)
|
141
|
+
|
142
|
+
begin
|
143
|
+
data = JSON.parse(File.read(SELECTION_FILE))
|
144
|
+
repo_path = run_command("git rev-parse --show-toplevel").strip
|
145
|
+
|
146
|
+
if valid_selection?(data, repo_path)
|
147
|
+
return data['selected']
|
148
|
+
end
|
149
|
+
rescue => e
|
150
|
+
# If there's an error, just ignore the saved selections
|
151
|
+
warn "Warning: Failed to load selections: #{e.message}" if ENV['SNAKOMMIT_DEBUG']
|
152
|
+
end
|
153
|
+
|
154
|
+
nil
|
155
|
+
end
|
156
|
+
|
157
|
+
# Clear saved selections
|
158
|
+
def clear_saved_selections
|
159
|
+
File.delete(SELECTION_FILE) if File.exist?(SELECTION_FILE)
|
160
|
+
rescue => e
|
161
|
+
# Silently fail
|
162
|
+
warn "Warning: Failed to clear selections: #{e.message}" if ENV['SNAKOMMIT_DEBUG']
|
163
|
+
end
|
164
|
+
|
165
|
+
private
|
166
|
+
|
167
|
+
def invalidate_status_cache
|
168
|
+
@cache.invalidate(:staged_files)
|
169
|
+
@cache.invalidate(:unstaged_files)
|
170
|
+
@cache.invalidate(:untracked_files)
|
171
|
+
end
|
172
|
+
|
173
|
+
def valid_selection?(data, repo_path)
|
174
|
+
return false unless data.is_a?(Hash)
|
175
|
+
return false unless data['repo'] == repo_path
|
176
|
+
return false unless data['selected'].is_a?(Array)
|
177
|
+
|
178
|
+
# Check if the selection is recent (less than TTL seconds old)
|
179
|
+
timestamp = data['timestamp'].to_i
|
180
|
+
Time.now.to_i - timestamp < SELECTION_TTL
|
181
|
+
end
|
182
|
+
|
183
|
+
def validate_git_repo
|
184
|
+
unless self.class.in_repo?
|
185
|
+
raise GitError, "Not in a Git repository"
|
186
|
+
end
|
187
|
+
end
|
188
|
+
|
189
|
+
def run_command(command)
|
190
|
+
# Use the command pool for better performance
|
191
|
+
CommandPool.instance.execute(command)
|
192
|
+
rescue => e
|
193
|
+
raise GitError, "Failed to run Git command: #{e.message}"
|
194
|
+
end
|
195
|
+
|
196
|
+
def with_temp_file(content)
|
197
|
+
# Create temporary file for commit message to avoid shell escaping issues
|
198
|
+
message_file = File.join(@path, '.git', 'COMMIT_EDITMSG')
|
199
|
+
File.write(message_file, content)
|
200
|
+
|
201
|
+
yield(message_file)
|
202
|
+
ensure
|
203
|
+
# Clean up temp file
|
204
|
+
File.unlink(message_file) if File.exist?(message_file)
|
205
|
+
end
|
206
|
+
|
207
|
+
def shell_escape(str)
|
208
|
+
# Simple shell escaping for file paths
|
209
|
+
str.to_s.gsub(/([^A-Za-z0-9_\-\.\/])/, '\\\\\\1')
|
210
|
+
end
|
211
|
+
end
|
212
|
+
end
|
@@ -0,0 +1,258 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'fileutils'
|
4
|
+
|
5
|
+
module Snakommit
|
6
|
+
# Manages Git hooks integration
|
7
|
+
class Hooks
|
8
|
+
class HookError < StandardError; end
|
9
|
+
|
10
|
+
# Templates for various Git hooks
|
11
|
+
HOOK_TEMPLATES = {
|
12
|
+
'prepare-commit-msg' => <<~HOOK,
|
13
|
+
#!/bin/sh
|
14
|
+
# Snakommit prepare-commit-msg hook
|
15
|
+
|
16
|
+
# Skip if message is already prepared (e.g., merge)
|
17
|
+
if [ -n "$2" ]; then
|
18
|
+
exit 0
|
19
|
+
fi
|
20
|
+
|
21
|
+
# Capture original commit message if it exists
|
22
|
+
original_message=""
|
23
|
+
if [ -s "$1" ]; then
|
24
|
+
original_message=$(cat "$1")
|
25
|
+
fi
|
26
|
+
|
27
|
+
# Run snakommit and use its output as the commit message
|
28
|
+
# If snakommit exits with non-zero, fall back to original message
|
29
|
+
message=$(snakommit prepare-message 2>/dev/null)
|
30
|
+
if [ $? -eq 0 ]; then
|
31
|
+
echo "$message" > "$1"
|
32
|
+
else
|
33
|
+
if [ -n "$original_message" ]; then
|
34
|
+
echo "$original_message" > "$1"
|
35
|
+
fi
|
36
|
+
fi
|
37
|
+
HOOK
|
38
|
+
|
39
|
+
'commit-msg' => <<~HOOK,
|
40
|
+
#!/bin/sh
|
41
|
+
# Snakommit commit-msg hook
|
42
|
+
|
43
|
+
# Validate the commit message using snakommit
|
44
|
+
snakommit validate-message "$1"
|
45
|
+
if [ $? -ne 0 ]; then
|
46
|
+
echo "ERROR: Your commit message does not conform to standard format."
|
47
|
+
echo "Please run 'snakommit help format' for more information."
|
48
|
+
exit 1
|
49
|
+
fi
|
50
|
+
HOOK
|
51
|
+
|
52
|
+
'post-commit' => <<~HOOK
|
53
|
+
#!/bin/sh
|
54
|
+
# Snakommit post-commit hook
|
55
|
+
|
56
|
+
# Log this commit in snakommit's history
|
57
|
+
snakommit log-commit $(git log -1 --format="%H") >/dev/null 2>&1
|
58
|
+
HOOK
|
59
|
+
}.freeze
|
60
|
+
|
61
|
+
# Magic signature to identify our hooks
|
62
|
+
HOOK_SIGNATURE = '# Snakommit'.freeze
|
63
|
+
|
64
|
+
# Initialize with a Git repository path
|
65
|
+
# @param git_repo_path [String] Path to Git repository, defaults to current directory
|
66
|
+
def initialize(git_repo_path = Dir.pwd)
|
67
|
+
@git_repo_path = git_repo_path
|
68
|
+
@hooks_dir = File.join(@git_repo_path, '.git', 'hooks')
|
69
|
+
@hook_status_cache = {}
|
70
|
+
ensure_hooks_directory
|
71
|
+
end
|
72
|
+
|
73
|
+
# Install one or all hooks
|
74
|
+
# @param hook_name [String, nil] Specific hook to install, or nil for all hooks
|
75
|
+
# @return [Boolean] Success status
|
76
|
+
def install(hook_name = nil)
|
77
|
+
# Invalidate status cache
|
78
|
+
@hook_status_cache.clear
|
79
|
+
|
80
|
+
if hook_name
|
81
|
+
install_hook(hook_name)
|
82
|
+
else
|
83
|
+
# Use batch processing if available
|
84
|
+
if defined?(Performance::BatchProcessor)
|
85
|
+
batch_processor = Performance::BatchProcessor.new(3)
|
86
|
+
result = true
|
87
|
+
batch_processor.process_files(HOOK_TEMPLATES.keys) do |batch|
|
88
|
+
batch.each do |hook|
|
89
|
+
result = false unless install_hook(hook)
|
90
|
+
end
|
91
|
+
end
|
92
|
+
result
|
93
|
+
else
|
94
|
+
HOOK_TEMPLATES.keys.all? { |hook| install_hook(hook) }
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
# Uninstall one or all hooks
|
100
|
+
# @param hook_name [String, nil] Specific hook to uninstall, or nil for all hooks
|
101
|
+
# @return [Boolean] Success status
|
102
|
+
def uninstall(hook_name = nil)
|
103
|
+
# Invalidate status cache
|
104
|
+
@hook_status_cache.clear
|
105
|
+
|
106
|
+
if hook_name
|
107
|
+
uninstall_hook(hook_name)
|
108
|
+
else
|
109
|
+
HOOK_TEMPLATES.keys.all? { |hook| uninstall_hook(hook) }
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
# Check the status of installed hooks
|
114
|
+
# @return [Hash] Status of each hook (:installed, :conflict, or :not_installed)
|
115
|
+
def status
|
116
|
+
# Use cached status if available and not empty
|
117
|
+
return @hook_status_cache if @hook_status_cache.any?
|
118
|
+
|
119
|
+
@hook_status_cache = HOOK_TEMPLATES.keys.each_with_object({}) do |hook_name, status|
|
120
|
+
hook_path = File.join(@hooks_dir, hook_name)
|
121
|
+
|
122
|
+
if File.exist?(hook_path)
|
123
|
+
if hook_is_snakommit?(hook_path)
|
124
|
+
status[hook_name] = :installed
|
125
|
+
else
|
126
|
+
status[hook_name] = :conflict
|
127
|
+
end
|
128
|
+
else
|
129
|
+
status[hook_name] = :not_installed
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
private
|
135
|
+
|
136
|
+
# Ensure hooks directory exists
|
137
|
+
# @raise [HookError] If hooks directory not found
|
138
|
+
def ensure_hooks_directory
|
139
|
+
unless Dir.exist?(@hooks_dir)
|
140
|
+
raise HookError, "Git hooks directory not found. Are you in a Git repository?"
|
141
|
+
end
|
142
|
+
end
|
143
|
+
|
144
|
+
# Install a specific hook
|
145
|
+
# @param hook_name [String] Name of the hook to install
|
146
|
+
# @return [Boolean] Success status
|
147
|
+
# @raise [HookError] If hook installation fails
|
148
|
+
def install_hook(hook_name)
|
149
|
+
unless HOOK_TEMPLATES.key?(hook_name)
|
150
|
+
raise HookError, "Unknown hook: #{hook_name}"
|
151
|
+
end
|
152
|
+
|
153
|
+
hook_path = File.join(@hooks_dir, hook_name)
|
154
|
+
|
155
|
+
# Only backup if the hook exists and is not already a Snakommit hook
|
156
|
+
if File.exist?(hook_path) && !hook_is_snakommit?(hook_path)
|
157
|
+
backup_existing_hook(hook_path)
|
158
|
+
end
|
159
|
+
|
160
|
+
# Write the hook file in a single operation
|
161
|
+
File.write(hook_path, HOOK_TEMPLATES[hook_name])
|
162
|
+
FileUtils.chmod(0755, hook_path) # Make hook executable
|
163
|
+
|
164
|
+
true
|
165
|
+
rescue Errno::EACCES => e
|
166
|
+
raise HookError, "Permission denied: #{e.message}"
|
167
|
+
rescue => e
|
168
|
+
raise HookError, "Failed to install hook: #{e.message}"
|
169
|
+
end
|
170
|
+
|
171
|
+
# Uninstall a specific hook
|
172
|
+
# @param hook_name [String] Name of the hook to uninstall
|
173
|
+
# @return [Boolean] Success status
|
174
|
+
# @raise [HookError] If hook uninstallation fails
|
175
|
+
def uninstall_hook(hook_name)
|
176
|
+
hook_path = File.join(@hooks_dir, hook_name)
|
177
|
+
|
178
|
+
# Only remove if it's our hook
|
179
|
+
if hook_is_snakommit?(hook_path)
|
180
|
+
# Try to restore backup first, delete if no backup
|
181
|
+
restore_backup_hook(hook_path) || File.delete(hook_path)
|
182
|
+
true
|
183
|
+
else
|
184
|
+
false
|
185
|
+
end
|
186
|
+
rescue Errno::EACCES => e
|
187
|
+
raise HookError, "Permission denied: #{e.message}"
|
188
|
+
rescue => e
|
189
|
+
raise HookError, "Failed to uninstall hook: #{e.message}"
|
190
|
+
end
|
191
|
+
|
192
|
+
# Check if a hook file is a Snakommit hook
|
193
|
+
# @param hook_path [String] Path to the hook file
|
194
|
+
# @return [Boolean] True if it's a Snakommit hook
|
195
|
+
def hook_is_snakommit?(hook_path)
|
196
|
+
return false unless File.exist?(hook_path)
|
197
|
+
|
198
|
+
# Fast check - read first few lines only
|
199
|
+
File.open(hook_path) do |file|
|
200
|
+
10.times do # Increase the number of lines checked
|
201
|
+
line = file.gets
|
202
|
+
return true if line && line.include?(HOOK_SIGNATURE)
|
203
|
+
break if line.nil?
|
204
|
+
end
|
205
|
+
end
|
206
|
+
|
207
|
+
false
|
208
|
+
rescue => e
|
209
|
+
# If we can't read the file, it's not our hook
|
210
|
+
false
|
211
|
+
end
|
212
|
+
|
213
|
+
# Backup an existing hook file
|
214
|
+
# @param hook_path [String] Path to the hook file
|
215
|
+
# @return [String, nil] Path to the backup file or nil if no backup created
|
216
|
+
def backup_existing_hook(hook_path)
|
217
|
+
# Don't overwrite existing backup
|
218
|
+
backup_path = "#{hook_path}.backup"
|
219
|
+
if File.exist?(backup_path)
|
220
|
+
backup_path = "#{hook_path}.backup.#{Time.now.to_i}"
|
221
|
+
end
|
222
|
+
|
223
|
+
# Create the backup
|
224
|
+
FileUtils.cp(hook_path, backup_path)
|
225
|
+
backup_path
|
226
|
+
rescue => e
|
227
|
+
# Log but continue if backup fails
|
228
|
+
warn "Warning: Failed to backup hook at #{hook_path}: #{e.message}" if ENV['SNAKOMMIT_DEBUG']
|
229
|
+
nil
|
230
|
+
end
|
231
|
+
|
232
|
+
# Restore a backed-up hook file
|
233
|
+
# @param hook_path [String] Path to the original hook file
|
234
|
+
# @return [Boolean] True if backup was restored, false otherwise
|
235
|
+
def restore_backup_hook(hook_path)
|
236
|
+
backup_path = "#{hook_path}.backup"
|
237
|
+
|
238
|
+
# Check for timestamped backups if standard one doesn't exist
|
239
|
+
unless File.exist?(backup_path)
|
240
|
+
backup_glob = "#{hook_path}.backup.*"
|
241
|
+
backups = Dir.glob(backup_glob).sort_by { |f| File.mtime(f) }
|
242
|
+
backup_path = backups.last unless backups.empty?
|
243
|
+
end
|
244
|
+
|
245
|
+
# Restore the backup if it exists
|
246
|
+
if File.exist?(backup_path)
|
247
|
+
FileUtils.mv(backup_path, hook_path)
|
248
|
+
true
|
249
|
+
else
|
250
|
+
false
|
251
|
+
end
|
252
|
+
rescue => e
|
253
|
+
# Log but continue if restore fails
|
254
|
+
warn "Warning: Failed to restore hook backup at #{backup_path}: #{e.message}" if ENV['SNAKOMMIT_DEBUG']
|
255
|
+
false
|
256
|
+
end
|
257
|
+
end
|
258
|
+
end
|