ace-git-commit 0.23.0

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.
Files changed (35) hide show
  1. checksums.yaml +7 -0
  2. data/.ace-defaults/git/commit.yml +22 -0
  3. data/.ace-defaults/nav/protocols/guide-sources/ace-git-commit.yml +10 -0
  4. data/.ace-defaults/nav/protocols/prompt-sources/ace-git-commit.yml +19 -0
  5. data/.ace-defaults/nav/protocols/wfi-sources/ace-git-commit.yml +19 -0
  6. data/CHANGELOG.md +404 -0
  7. data/COMPARISON.md +176 -0
  8. data/LICENSE +21 -0
  9. data/README.md +44 -0
  10. data/Rakefile +14 -0
  11. data/exe/ace-git-commit +13 -0
  12. data/handbook/guides/version-control-system-message.g.md +507 -0
  13. data/handbook/prompts/git-commit.md +22 -0
  14. data/handbook/prompts/git-commit.system.md +150 -0
  15. data/handbook/skills/as-git-commit/SKILL.md +57 -0
  16. data/handbook/workflow-instructions/git/commit.wf.md +75 -0
  17. data/lib/ace/git_commit/atoms/git_executor.rb +62 -0
  18. data/lib/ace/git_commit/atoms/gitignore_checker.rb +118 -0
  19. data/lib/ace/git_commit/cli/commands/commit.rb +147 -0
  20. data/lib/ace/git_commit/cli.rb +23 -0
  21. data/lib/ace/git_commit/models/commit_group.rb +53 -0
  22. data/lib/ace/git_commit/models/commit_options.rb +75 -0
  23. data/lib/ace/git_commit/models/split_commit_result.rb +60 -0
  24. data/lib/ace/git_commit/models/stage_result.rb +71 -0
  25. data/lib/ace/git_commit/molecules/commit_grouper.rb +123 -0
  26. data/lib/ace/git_commit/molecules/commit_summarizer.rb +43 -0
  27. data/lib/ace/git_commit/molecules/diff_analyzer.rb +111 -0
  28. data/lib/ace/git_commit/molecules/file_stager.rb +153 -0
  29. data/lib/ace/git_commit/molecules/message_generator.rb +438 -0
  30. data/lib/ace/git_commit/molecules/path_resolver.rb +365 -0
  31. data/lib/ace/git_commit/molecules/split_commit_executor.rb +272 -0
  32. data/lib/ace/git_commit/organisms/commit_orchestrator.rb +330 -0
  33. data/lib/ace/git_commit/version.rb +7 -0
  34. data/lib/ace/git_commit.rb +41 -0
  35. metadata +149 -0
@@ -0,0 +1,438 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "json"
5
+
6
+ module Ace
7
+ module GitCommit
8
+ module Molecules
9
+ # MessageGenerator generates commit messages using LLM
10
+ class MessageGenerator
11
+ DEFAULT_MODEL = "glite"
12
+ MAX_TOKENS = 8192
13
+ SYSTEM_PROMPT_PATH = "ace-git-commit/handbook/prompts/git-commit.system.md"
14
+ COMMIT_HEADER_PATTERN = /\A(feat|fix|docs|style|refactor|test|chore|spec|perf|build|ci|revert)(\([^)]+\))?:\s+\S+/
15
+
16
+ class BatchParseError < StandardError; end
17
+
18
+ def initialize(config = nil)
19
+ @config = config || {}
20
+ @model = @config.fetch("model", DEFAULT_MODEL)
21
+ end
22
+
23
+ # Generate a commit message from diff
24
+ # @param diff [String] The git diff
25
+ # @param intention [String, nil] Optional intention/context
26
+ # @param files [Array<String>] List of changed files
27
+ # @param config [Hash, nil] Optional per-invocation config override
28
+ # @return [String] Generated commit message
29
+ def generate(diff, intention: nil, files: [], config: nil)
30
+ system_prompt = load_system_prompt
31
+ user_prompt = build_user_prompt(diff, intention, files)
32
+
33
+ # Use QueryInterface with named parameters matching CLI
34
+ response = Ace::LLM::QueryInterface.query(
35
+ resolve_model(config),
36
+ user_prompt,
37
+ system: system_prompt,
38
+ temperature: 0.7,
39
+ timeout: 60,
40
+ max_tokens: MAX_TOKENS
41
+ )
42
+
43
+ clean_commit_message(response[:text])
44
+ rescue Ace::LLM::Error => e
45
+ raise Error, "Failed to generate commit message: #{e.message}"
46
+ end
47
+
48
+ # Generate commit messages for multiple groups in one LLM call
49
+ # @param groups_context [Array<Hash>] Array of {scope_name:, diff:, files:, type_hint:, description:}
50
+ # @param intention [String, nil] Optional intention/context
51
+ # @param config [Hash, nil] Optional config override
52
+ # @return [Hash] { messages: Array<String>, order: Array<String> } with LLM-recommended order
53
+ def generate_batch(groups_context, intention: nil, config: nil)
54
+ return {messages: [], order: []} if groups_context.empty?
55
+
56
+ if groups_context.length == 1
57
+ msg = generate(groups_context.first[:diff], intention: intention, files: groups_context.first[:files], config: config)
58
+ return {messages: [msg], order: [groups_context.first[:scope_name]]}
59
+ end
60
+
61
+ system_prompt = load_batch_system_prompt
62
+ user_prompt = build_batch_user_prompt(groups_context, intention)
63
+
64
+ response = Ace::LLM::QueryInterface.query(
65
+ resolve_model(config),
66
+ user_prompt,
67
+ system: system_prompt,
68
+ temperature: 0.7,
69
+ timeout: 120,
70
+ max_tokens: MAX_TOKENS
71
+ )
72
+
73
+ parse_batch_response(response[:text], groups_context)
74
+ rescue BatchParseError => e
75
+ repaired = retry_batch_parse(groups_context, intention, response[:text], e.message, config)
76
+ return repaired if repaired
77
+
78
+ raise Error, "Failed to generate batch commit messages: #{e.message}"
79
+ rescue Ace::LLM::Error => e
80
+ raise Error, "Failed to generate batch commit messages: #{e.message}"
81
+ end
82
+
83
+ private
84
+
85
+ def load_batch_system_prompt
86
+ <<~PROMPT
87
+ You are a git commit message generator. Generate clear, concise commit messages following conventional commit format.
88
+
89
+ You will receive MULTIPLE groups of changes that need SEPARATE commit messages.
90
+ Each group represents a different scope/area of the codebase.
91
+
92
+ CRITICAL: Generate DISTINCT messages for each group. Each message must:
93
+ - Accurately describe what changed in THAT specific group
94
+ - Use different wording than other groups
95
+ - Focus on the specific scope/area of that group
96
+
97
+ Format for EACH message:
98
+ <type>(<scope>): <subject>
99
+
100
+ <body>
101
+
102
+ Types: feat, fix, docs, style, refactor, test, chore, spec
103
+
104
+ TYPE SELECTION - VERY IMPORTANT:
105
+ - Each group MAY have a "PREFERRED TYPE" hint - this hint applies ONLY to that specific group
106
+ - Groups WITHOUT a type hint: analyze the actual code changes and select appropriately:
107
+ * feat = new functionality, new features, new capabilities
108
+ * fix = bug fixes
109
+ * refactor = code restructuring without behavior change
110
+ * test = test additions/changes
111
+ * docs = documentation OF the software (user guides, API docs, README)
112
+ * chore = build/config changes only
113
+ * spec = specifications and artifacts from making software (task specs, planning docs, retros, ideas)
114
+ - DO NOT let hints from one group influence your type selection for other groups
115
+ - For code packages (lib/, src/, actual implementation): prefer feat/fix/refactor based on changes
116
+
117
+ COMMIT ORDER - Output groups in logical commit order:
118
+ - Implementation/feature code FIRST (the actual functionality)
119
+ - Supporting libraries/dependencies that the feature uses
120
+ - Configuration that enables/configures the feature
121
+ - Documentation, specs, retros LAST (they document what was done)
122
+
123
+ Rules of thumb:
124
+ - feat/fix commits usually come before chore/docs
125
+ - Core packages before config packages
126
+ - But use judgment - sometimes config must come first if it enables the feature
127
+
128
+ OUTPUT MUST BE STRICT JSON ONLY (no markdown, no prose, no code fences):
129
+ {
130
+ "order": ["scope-a", "scope-b"],
131
+ "messages": [
132
+ {"scope": "scope-a", "message": "feat(scope-a): ..."},
133
+ {"scope": "scope-b", "message": "fix(scope-b): ..."}
134
+ ]
135
+ }
136
+
137
+ HARD RULES:
138
+ - "order" must include every scope exactly once
139
+ - "messages" must include every scope exactly once
140
+ - each "message" must start with a valid conventional commit header
141
+ - do not use "chore" unless the diff is actually build/config/maintenance only
142
+ PROMPT
143
+ end
144
+
145
+ def build_batch_user_prompt(groups_context, intention)
146
+ prompt = []
147
+
148
+ if intention && !intention.empty?
149
+ prompt << "Overall intention/context: #{intention}"
150
+ prompt << ""
151
+ end
152
+
153
+ prompt << "Generate #{groups_context.length} DISTINCT commit messages for these groups."
154
+ prompt << "OUTPUT THEM IN YOUR RECOMMENDED COMMIT ORDER (implementation first, docs last)."
155
+ prompt << "Return STRICT JSON only with keys: order, messages."
156
+ prompt << "Messages format: <type>(<scope>): <subject>"
157
+ prompt << ""
158
+
159
+ groups_context.each_with_index do |ctx, _index|
160
+ prompt << "=" * 60
161
+ prompt << "SCOPE: #{ctx[:scope_name]}"
162
+ prompt << "=" * 60
163
+
164
+ # Include type hint OR explicit instruction to analyze
165
+ prompt << if ctx[:type_hint] && !ctx[:type_hint].to_s.empty?
166
+ "PREFERRED TYPE FOR THIS GROUP ONLY: #{ctx[:type_hint]}"
167
+ else
168
+ "TYPE: Analyze changes and select appropriate type (feat/fix/refactor/test/docs/chore)"
169
+ end
170
+
171
+ # Include description if provided
172
+ if ctx[:description] && !ctx[:description].to_s.empty?
173
+ prompt << "Scope context: #{ctx[:description]}"
174
+ end
175
+
176
+ prompt << ""
177
+
178
+ if ctx[:files] && !ctx[:files].empty?
179
+ prompt << "Files in this group:"
180
+ ctx[:files].each { |f| prompt << " - #{f}" }
181
+ prompt << ""
182
+ end
183
+
184
+ prompt << "Diff for this group:"
185
+ prompt << ctx[:diff]
186
+ prompt << ""
187
+ end
188
+
189
+ prompt.join("\n")
190
+ end
191
+
192
+ def parse_batch_response(response, groups_context)
193
+ return {messages: [clean_commit_message(response)], order: [groups_context.first[:scope_name]]} if groups_context.length == 1
194
+
195
+ scope_names = groups_context.map { |g| g[:scope_name] }
196
+ parsed = parse_batch_json(response)
197
+ order = Array(parsed["order"])
198
+ messages_array = Array(parsed["messages"])
199
+
200
+ message_by_scope = {}
201
+ messages_array.each do |item|
202
+ next unless item.is_a?(Hash)
203
+
204
+ scope = item["scope"] || item[:scope]
205
+ message = item["message"] || item[:message]
206
+ next if scope.nil? || message.nil?
207
+
208
+ message_by_scope[scope.to_s] = clean_commit_message(message.to_s)
209
+ end
210
+
211
+ validate_scope_list!("order", order, scope_names)
212
+ validate_scope_list!("messages", message_by_scope.keys, scope_names)
213
+
214
+ ordered_messages = order.map do |scope|
215
+ msg = message_by_scope[scope]
216
+ validate_commit_header!(scope, msg)
217
+ msg
218
+ end
219
+
220
+ {messages: ordered_messages, order: order}
221
+ end
222
+
223
+ def parse_batch_json(response)
224
+ raw = response.to_s.strip
225
+ raw = raw.gsub(/\A```(?:json)?\s*/i, "").gsub(/\s*```\z/, "").strip
226
+ raw = extract_json_block(raw)
227
+ JSON.parse(raw)
228
+ rescue JSON::ParserError => e
229
+ raise BatchParseError, "Invalid batch JSON: #{e.message}"
230
+ end
231
+
232
+ def extract_json_block(text)
233
+ start_idx = text.index("{")
234
+ end_idx = text.rindex("}")
235
+ raise BatchParseError, "Batch response does not include a JSON object." unless start_idx && end_idx
236
+
237
+ text[start_idx..end_idx]
238
+ end
239
+
240
+ def validate_scope_list!(label, actual, expected)
241
+ actual = actual.map(&:to_s)
242
+ missing = expected - actual
243
+ extra = actual - expected
244
+ duplicates = actual.group_by(&:itself).select { |_k, v| v.length > 1 }.keys
245
+
246
+ return if missing.empty? && extra.empty? && duplicates.empty?
247
+
248
+ parts = []
249
+ parts << "missing=#{missing.join(",")}" unless missing.empty?
250
+ parts << "extra=#{extra.join(",")}" unless extra.empty?
251
+ parts << "duplicates=#{duplicates.join(",")}" unless duplicates.empty?
252
+ raise BatchParseError, "#{label} scope validation failed (#{parts.join(" | ")})"
253
+ end
254
+
255
+ def validate_commit_header!(scope, message)
256
+ return if message && message.match?(COMMIT_HEADER_PATTERN)
257
+
258
+ raise BatchParseError, "Invalid commit header for scope '#{scope}': #{message.inspect}"
259
+ end
260
+
261
+ def retry_batch_parse(groups_context, intention, previous_response, reason, config)
262
+ warn "[ace-git-commit] Batch parse failed, retrying with strict JSON repair: #{reason}"
263
+
264
+ repair_prompt = build_batch_repair_user_prompt(groups_context, intention, previous_response, reason)
265
+ repair_response = Ace::LLM::QueryInterface.query(
266
+ resolve_model(config),
267
+ repair_prompt,
268
+ system: load_batch_system_prompt,
269
+ temperature: 0.2,
270
+ timeout: 120,
271
+ max_tokens: MAX_TOKENS
272
+ )
273
+
274
+ parse_batch_response(repair_response[:text], groups_context)
275
+ rescue BatchParseError => e
276
+ warn "[ace-git-commit] Batch parse retry failed: #{e.message}"
277
+ nil
278
+ end
279
+
280
+ def build_batch_repair_user_prompt(groups_context, intention, bad_response, reason)
281
+ scope_names = groups_context.map { |g| g[:scope_name] }
282
+ prompt = []
283
+ prompt << "Your previous response was invalid for strict JSON batch commit output."
284
+ prompt << "Reason: #{reason}"
285
+ prompt << "Allowed scopes: #{scope_names.join(", ")}"
286
+ prompt << "Intention/context: #{intention}" if intention && !intention.empty?
287
+ prompt << ""
288
+ prompt << "Previous response:"
289
+ prompt << bad_response.to_s
290
+ prompt << ""
291
+ prompt << "Return ONLY valid JSON in this exact shape:"
292
+ prompt << '{"order":["scope-a","scope-b"],"messages":[{"scope":"scope-a","message":"feat(scope-a): ..."},{"scope":"scope-b","message":"fix(scope-b): ..."}]}'
293
+ prompt.join("\n")
294
+ end
295
+
296
+ def resolve_model(config_override)
297
+ return @model unless config_override.is_a?(Hash)
298
+
299
+ config_override.fetch("model", @model)
300
+ end
301
+
302
+ # Load system prompt from template
303
+ # @return [String] System prompt content
304
+ def load_system_prompt
305
+ # Try to find the prompt in the project structure
306
+ prompt_path = find_system_prompt_path
307
+
308
+ if prompt_path && File.exist?(prompt_path)
309
+ File.read(prompt_path)
310
+ else
311
+ # Fallback to embedded prompt
312
+ default_system_prompt
313
+ end
314
+ end
315
+
316
+ # Find the system prompt file path
317
+ # @return [String, nil] Path to system prompt or nil
318
+ def find_system_prompt_path
319
+ # Look for ace-git-commit/handbook in current directory or parent directories
320
+ current = Pathname.pwd
321
+
322
+ while current.parent != current
323
+ prompt_file = current.join(SYSTEM_PROMPT_PATH)
324
+ return prompt_file.to_s if prompt_file.exist?
325
+
326
+ # Also check if we're already in ace
327
+ if current.basename.to_s == "ace-git-commit"
328
+ parent_prompt = current.parent.join(SYSTEM_PROMPT_PATH)
329
+ return parent_prompt.to_s if parent_prompt.exist?
330
+ end
331
+
332
+ current = current.parent
333
+ end
334
+
335
+ nil
336
+ end
337
+
338
+ # Build user prompt from diff and context
339
+ # @param diff [String] The git diff
340
+ # @param intention [String, nil] Optional intention
341
+ # @param files [Array<String>] Changed files
342
+ # @return [String] User prompt
343
+ def build_user_prompt(diff, intention, files)
344
+ prompt = []
345
+
346
+ if intention && !intention.empty?
347
+ prompt << "Intention/Context: #{intention}"
348
+ prompt << ""
349
+ end
350
+
351
+ if files && !files.empty?
352
+ prompt << "Changed files:"
353
+ files.each { |f| prompt << " - #{f}" }
354
+ prompt << ""
355
+ end
356
+
357
+ prompt << "Git diff:"
358
+ prompt << diff
359
+
360
+ prompt.join("\n")
361
+ end
362
+
363
+ # Clean and format the generated commit message
364
+ # @param message [String] Raw generated message
365
+ # @return [String] Cleaned message
366
+ def clean_commit_message(message)
367
+ return "" if message.nil?
368
+
369
+ # Remove any markdown code blocks
370
+ message = message.gsub(/```[a-z]*\n?/, "")
371
+ message = message.gsub(/```\n?/, "")
372
+
373
+ # Remove leading/trailing whitespace
374
+ message = message.strip
375
+
376
+ # Ensure proper formatting
377
+ lines = message.lines.map(&:rstrip)
378
+
379
+ # Remove empty lines at the beginning
380
+ while lines.first && lines.first.strip.empty?
381
+ lines.shift
382
+ end
383
+
384
+ # Ensure single blank line between title and body
385
+ if lines.length > 1
386
+ # Find the first non-empty line after the title
387
+ title_index = 0
388
+ body_start = 1
389
+
390
+ while body_start < lines.length && lines[body_start].strip.empty?
391
+ body_start += 1
392
+ end
393
+
394
+ if body_start < lines.length
395
+ # Reconstruct with single blank line
396
+ result = [lines[title_index]]
397
+ result << ""
398
+ result.concat(lines[body_start..-1])
399
+ lines = result
400
+ end
401
+ end
402
+
403
+ lines.join("\n")
404
+ end
405
+
406
+ # Default system prompt if template not found
407
+ # @return [String] Default prompt
408
+ def default_system_prompt
409
+ <<~PROMPT
410
+ You are a git commit message generator. Generate clear, concise commit messages following conventional commit format.
411
+
412
+ Format:
413
+ <type>(<scope>): <subject>
414
+
415
+ <body>
416
+
417
+ Types:
418
+ - feat: New feature
419
+ - fix: Bug fix
420
+ - docs: Documentation changes
421
+ - style: Code style changes (formatting, etc.)
422
+ - refactor: Code refactoring
423
+ - test: Test changes
424
+ - chore: Build process or auxiliary tool changes
425
+
426
+ Rules:
427
+ - Subject line: max 72 characters, imperative mood
428
+ - Scope: optional, component or area affected
429
+ - Body: explain what and why, not how
430
+ - Keep messages clear and professional
431
+
432
+ Generate only the commit message, no additional commentary.
433
+ PROMPT
434
+ end
435
+ end
436
+ end
437
+ end
438
+ end