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.
@@ -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