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 
         
     |