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,330 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module GitCommit
5
+ module Organisms
6
+ # CommitOrchestrator coordinates the entire commit process
7
+ class CommitOrchestrator
8
+ # Reference the default scope name constant
9
+ DEFAULT_SCOPE_NAME = Ace::Support::Config::Models::ConfigGroup::DEFAULT_SCOPE_NAME
10
+ def initialize(config = nil)
11
+ @config = config || load_config
12
+ @git = Atoms::GitExecutor.new
13
+ @diff_analyzer = Molecules::DiffAnalyzer.new(@git)
14
+ @file_stager = Molecules::FileStager.new(@git)
15
+ @path_resolver = Molecules::PathResolver.new(@git)
16
+ @message_generator = Molecules::MessageGenerator.new(@config)
17
+ @commit_grouper = Molecules::CommitGrouper.new
18
+ @split_commit_executor = Molecules::SplitCommitExecutor.new(
19
+ git_executor: @git,
20
+ diff_analyzer: @diff_analyzer,
21
+ file_stager: @file_stager,
22
+ message_generator: @message_generator
23
+ )
24
+ end
25
+
26
+ # Execute the commit process
27
+ # @param options [Models::CommitOptions] Commit options
28
+ # @return [Boolean] True if successful
29
+ def execute(options)
30
+ validate_repository!
31
+
32
+ if options.debug
33
+ puts "Debug: Commit options:"
34
+ options.to_h.each { |k, v| puts " #{k}: #{v.inspect}" }
35
+ end
36
+
37
+ # Stage files if needed
38
+ staging_result = stage_changes(options)
39
+
40
+ # Stop if staging failed
41
+ unless staging_result
42
+ puts "\nCannot proceed with commit due to staging failure" unless options.quiet
43
+ return false
44
+ end
45
+
46
+ # Ensure we have changes to commit
47
+ has_staged_changes = @git.has_staged_changes?
48
+ unless has_staged_changes
49
+ puts "No changes to commit" unless options.quiet
50
+ return true
51
+ end
52
+ puts "✓ Changes staged successfully" if options.stage_all? && !options.quiet
53
+
54
+ staged_files = @file_stager.staged_files
55
+ groups = @commit_grouper.group(staged_files, project_root: @git.repository_root)
56
+
57
+ if options.no_split
58
+ message = get_commit_message(options, config_override: @config)
59
+ return handle_single_commit(message, options)
60
+ end
61
+
62
+ if groups.length > 1
63
+ display_split_summary(groups) unless options.quiet
64
+ result = @split_commit_executor.execute(groups, options)
65
+ return result.success?
66
+ end
67
+
68
+ group_config = groups.first ? groups.first.config : @config
69
+ message = get_commit_message(options, config_override: group_config)
70
+ handle_single_commit(message, options)
71
+ end
72
+
73
+ private
74
+
75
+ # Load configuration with gem defaults and user overrides
76
+ # Follows ADR-022: Configuration Default and Override Pattern
77
+ # Uses Ace::Support::Config.create() for configuration cascade resolution
78
+ # @return [Hash] Configuration
79
+ def load_config
80
+ gem_root = Gem.loaded_specs["ace-git-commit"]&.gem_dir ||
81
+ File.expand_path("../../../..", __dir__)
82
+
83
+ resolver = Ace::Support::Config.create(
84
+ config_dir: ".ace",
85
+ defaults_dir: ".ace-defaults",
86
+ gem_path: gem_root
87
+ )
88
+
89
+ # Resolve config for git/commit namespace
90
+ config = resolver.resolve_namespace("git", filename: "commit")
91
+
92
+ # Extract git section if present, otherwise use root
93
+ config.data["git"] || config.data
94
+ rescue => e
95
+ warn "Error loading git commit config: #{e.message}" if Ace::GitCommit.debug?
96
+ {}
97
+ end
98
+
99
+ # Validate we're in a git repository
100
+ # @raise [GitError] If not in a repository
101
+ def validate_repository!
102
+ unless @git.in_repository?
103
+ raise GitError, "Not in a git repository"
104
+ end
105
+ end
106
+
107
+ # Stage changes based on options
108
+ # @param options [Models::CommitOptions] Options
109
+ # @return [Boolean] True if staging successful
110
+ def stage_changes(options)
111
+ if options.specific_files?
112
+ stage_specific_files(options)
113
+ elsif options.stage_all?
114
+ stage_all_changes(options)
115
+ else
116
+ true
117
+ end
118
+ end
119
+
120
+ # Stage specific files with progress feedback
121
+ # @param options [Models::CommitOptions] Options
122
+ # @return [Boolean] True if successful
123
+ def stage_specific_files(options)
124
+ # Early validation: check for non-existent paths (exclude glob patterns)
125
+ non_glob_paths = options.files.reject { |f| @path_resolver.glob_pattern?(f) }
126
+ unless non_glob_paths.empty?
127
+ validation = @path_resolver.validate_paths(non_glob_paths)
128
+ if validation[:invalid].any?
129
+ puts "\n✗ Invalid path(s): #{validation[:invalid].join(", ")}"
130
+ if @path_resolver.last_error
131
+ puts "Git error: #{@path_resolver.last_error}"
132
+ else
133
+ puts "These paths do not exist or have no git changes. Please check the paths and try again."
134
+ end
135
+ return false
136
+ end
137
+ end
138
+
139
+ # Separate paths by type for different handling
140
+ glob_patterns = options.files.select { |f| @path_resolver.glob_pattern?(f) }
141
+ non_patterns = options.files - glob_patterns
142
+ directories = non_patterns.select { |f| File.directory?(f) }
143
+ single_files = non_patterns - directories
144
+
145
+ # Build list of paths to stage
146
+ # - Directories: pass through directly (git add handles gitignore)
147
+ # - Globs: expand to tracked files (requires filesystem traversal)
148
+ # - Single files: pass through
149
+ paths_to_stage = directories + single_files
150
+
151
+ # Expand glob patterns to committable files (tracked + untracked)
152
+ unless glob_patterns.empty?
153
+ resolved_globs = @path_resolver.resolve_paths(glob_patterns)
154
+ if resolved_globs.empty?
155
+ puts "\n✗ No files found matching the specified pattern(s)"
156
+
157
+ # Collect suggestions for simple glob patterns
158
+ suggestions = glob_patterns.filter_map do |pattern|
159
+ suggested = @path_resolver.suggest_recursive_pattern(pattern)
160
+ {original: pattern, suggested: suggested} if suggested
161
+ end
162
+
163
+ # Output consolidated hint if any suggestions exist
164
+ unless suggestions.empty?
165
+ puts "\nHint: The following pattern(s) only match files at the current directory level:"
166
+ suggestions.each do |s|
167
+ puts " '#{s[:original]}' → try '#{s[:suggested]}' for recursive matching"
168
+ end
169
+ end
170
+
171
+ return false
172
+ end
173
+ paths_to_stage.concat(resolved_globs)
174
+ end
175
+
176
+ if paths_to_stage.empty?
177
+ puts "\n✗ No files found matching the specified path(s)"
178
+ return false
179
+ end
180
+
181
+ # Stage using path-restricted approach (reset + selective add)
182
+ puts "Staging files from specified path(s)..." unless options.quiet
183
+ result = @file_stager.stage_paths(paths_to_stage)
184
+
185
+ if result
186
+ staged_count = @file_stager.staged_files.length
187
+ puts "✓ Successfully staged #{staged_count} file(s)" unless options.quiet
188
+ true
189
+ else
190
+ # Always show errors, even in quiet mode
191
+ puts "\n✗ Failed to stage files"
192
+ puts "Error: #{@file_stager.last_error}" if @file_stager.last_error
193
+ # Suggestions only in verbose mode
194
+ unless options.quiet
195
+ puts "\nSuggestion: Check file permissions and paths"
196
+ end
197
+ false
198
+ end
199
+ end
200
+
201
+ # Stage all changes with progress feedback
202
+ # @param options [Models::CommitOptions] Options
203
+ # @return [Boolean] True if successful
204
+ def stage_all_changes(options)
205
+ result = @file_stager.stage_all
206
+
207
+ if result
208
+ true
209
+ else
210
+ # Always show errors, even in quiet mode
211
+ puts "\n✗ Failed to stage changes"
212
+ puts "Error: #{@file_stager.last_error}" if @file_stager.last_error
213
+
214
+ # Suggestions only in verbose mode
215
+ unless options.quiet
216
+ puts "\nSuggestions:"
217
+ puts " 1. Check file permissions"
218
+ puts " 2. Run 'git status' to see unstaged files"
219
+ puts " 3. Use --only-staged to commit existing staged files"
220
+ end
221
+ false
222
+ end
223
+ end
224
+
225
+ # Get or generate commit message
226
+ # @param options [Models::CommitOptions] Options
227
+ # @return [String] Commit message
228
+ def get_commit_message(options, config_override: nil)
229
+ if options.use_llm?
230
+ generate_message(options, config_override: config_override)
231
+ else
232
+ options.message
233
+ end
234
+ end
235
+
236
+ # Generate commit message using LLM
237
+ # @param options [Models::CommitOptions] Options
238
+ # @return [String] Generated message
239
+ def generate_message(options, config_override: nil)
240
+ puts "Generating commit message..." unless options.quiet
241
+
242
+ # Get the diff
243
+ diff = @diff_analyzer.get_staged_diff
244
+ files = @diff_analyzer.changed_files(staged_only: true)
245
+
246
+ config = config_override || @config
247
+ config = config.merge("model" => options.model) if options.model
248
+
249
+ message = @message_generator.generate(
250
+ diff,
251
+ intention: options.intention,
252
+ files: files,
253
+ config: config
254
+ )
255
+
256
+ puts "✓ Message generated" unless options.quiet
257
+ puts "\nMessage:\n#{message}" if options.debug
258
+
259
+ message
260
+ end
261
+
262
+ def handle_single_commit(message, options)
263
+ if options.dry_run
264
+ show_dry_run(message, options)
265
+ return true
266
+ end
267
+
268
+ perform_commit(message, options)
269
+ end
270
+
271
+ def display_split_summary(groups)
272
+ puts "Detected #{groups.length} configuration scopes:"
273
+ groups.each do |group|
274
+ label = group.scope_name.to_s.empty? ? DEFAULT_SCOPE_NAME : group.scope_name
275
+ source = group.source ? " (#{group.source})" : ""
276
+ puts " - #{label}#{source}: #{group.file_count} file(s)"
277
+ end
278
+ end
279
+
280
+ # Show dry run information
281
+ # @param message [String] Commit message
282
+ # @param options [Models::CommitOptions] Options
283
+ def show_dry_run(message, options)
284
+ puts "=== DRY RUN ==="
285
+ puts "Would commit with message:"
286
+ puts "-" * 40
287
+ puts message
288
+ puts "-" * 40
289
+
290
+ staged_files = @file_stager.staged_files
291
+ puts "\nFiles to be committed:"
292
+ staged_files.each { |f| puts " #{f}" }
293
+
294
+ if options.debug
295
+ diff_summary = @diff_analyzer.analyze_diff(@diff_analyzer.get_staged_diff)
296
+ puts "\nChanges:"
297
+ puts " Insertions: +#{diff_summary[:insertions]}"
298
+ puts " Deletions: -#{diff_summary[:deletions]}"
299
+ end
300
+ end
301
+
302
+ # Perform the actual commit
303
+ # @param message [String] Commit message
304
+ # @param options [Models::CommitOptions] Options
305
+ # @return [Boolean] True if successful
306
+ def perform_commit(message, options)
307
+ puts "Committing..." unless options.quiet
308
+
309
+ # Execute commit
310
+ @git.execute("commit", "-m", message)
311
+
312
+ # Get the commit SHA
313
+ commit_sha = @git.execute("rev-parse", "HEAD").strip
314
+
315
+ # Display commit summary
316
+ unless options.quiet
317
+ summarizer = Molecules::CommitSummarizer.new(@git)
318
+ summary = summarizer.summarize(commit_sha)
319
+ puts summary
320
+ end
321
+
322
+ true
323
+ rescue GitError => e
324
+ puts "\n✗ Commit failed: #{e.message}"
325
+ false
326
+ end
327
+ end
328
+ end
329
+ end
330
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module GitCommit
5
+ VERSION = "0.23.0"
6
+ end
7
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "git_commit/version"
4
+
5
+ # Load ace-config and ace-llm
6
+ require "ace/support/config"
7
+ require "ace/llm"
8
+
9
+ # Require all components
10
+ require_relative "git_commit/atoms/git_executor"
11
+ require_relative "git_commit/atoms/gitignore_checker"
12
+ require_relative "git_commit/molecules/diff_analyzer"
13
+ require_relative "git_commit/molecules/message_generator"
14
+ require_relative "git_commit/molecules/file_stager"
15
+ require_relative "git_commit/molecules/path_resolver"
16
+ require_relative "git_commit/molecules/commit_grouper"
17
+ require_relative "git_commit/molecules/commit_summarizer"
18
+ require_relative "git_commit/molecules/split_commit_executor"
19
+ require_relative "git_commit/organisms/commit_orchestrator"
20
+ require_relative "git_commit/models/commit_options"
21
+ require_relative "git_commit/models/commit_group"
22
+ require_relative "git_commit/models/split_commit_result"
23
+ require_relative "git_commit/models/stage_result"
24
+ require_relative "git_commit/cli"
25
+
26
+ module Ace
27
+ module GitCommit
28
+ class Error < StandardError; end
29
+ class GitError < Error; end
30
+ class ConfigurationError < Error; end
31
+
32
+ # Alias the default scope name constant from ace-support-config for convenience
33
+ DEFAULT_SCOPE_NAME = Ace::Support::Config::Models::ConfigGroup::DEFAULT_SCOPE_NAME
34
+
35
+ # Check if debug mode is enabled
36
+ # @return [Boolean] True if debug mode is enabled
37
+ def self.debug?
38
+ ENV["ACE_DEBUG"] == "1" || ENV["DEBUG"] == "1"
39
+ end
40
+ end
41
+ end
metadata ADDED
@@ -0,0 +1,149 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ace-git-commit
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.23.0
5
+ platform: ruby
6
+ authors:
7
+ - Michal Czyz
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: ace-support-cli
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.3'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.3'
26
+ - !ruby/object:Gem::Dependency
27
+ name: ace-support-core
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '0.24'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '0.24'
40
+ - !ruby/object:Gem::Dependency
41
+ name: ace-support-config
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '0.7'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '0.7'
54
+ - !ruby/object:Gem::Dependency
55
+ name: ace-git
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '0.11'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '0.11'
68
+ - !ruby/object:Gem::Dependency
69
+ name: ace-llm
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '0.23'
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '0.23'
82
+ description: Analyzes diffs and developer intent to generate conventional commit messages
83
+ using LLM. Handles monorepo scoping automatically — split commits across packages
84
+ with one command.
85
+ email:
86
+ - mc@cs3b.com
87
+ executables:
88
+ - ace-git-commit
89
+ extensions: []
90
+ extra_rdoc_files: []
91
+ files:
92
+ - ".ace-defaults/git/commit.yml"
93
+ - ".ace-defaults/nav/protocols/guide-sources/ace-git-commit.yml"
94
+ - ".ace-defaults/nav/protocols/prompt-sources/ace-git-commit.yml"
95
+ - ".ace-defaults/nav/protocols/wfi-sources/ace-git-commit.yml"
96
+ - CHANGELOG.md
97
+ - COMPARISON.md
98
+ - LICENSE
99
+ - README.md
100
+ - Rakefile
101
+ - exe/ace-git-commit
102
+ - handbook/guides/version-control-system-message.g.md
103
+ - handbook/prompts/git-commit.md
104
+ - handbook/prompts/git-commit.system.md
105
+ - handbook/skills/as-git-commit/SKILL.md
106
+ - handbook/workflow-instructions/git/commit.wf.md
107
+ - lib/ace/git_commit.rb
108
+ - lib/ace/git_commit/atoms/git_executor.rb
109
+ - lib/ace/git_commit/atoms/gitignore_checker.rb
110
+ - lib/ace/git_commit/cli.rb
111
+ - lib/ace/git_commit/cli/commands/commit.rb
112
+ - lib/ace/git_commit/models/commit_group.rb
113
+ - lib/ace/git_commit/models/commit_options.rb
114
+ - lib/ace/git_commit/models/split_commit_result.rb
115
+ - lib/ace/git_commit/models/stage_result.rb
116
+ - lib/ace/git_commit/molecules/commit_grouper.rb
117
+ - lib/ace/git_commit/molecules/commit_summarizer.rb
118
+ - lib/ace/git_commit/molecules/diff_analyzer.rb
119
+ - lib/ace/git_commit/molecules/file_stager.rb
120
+ - lib/ace/git_commit/molecules/message_generator.rb
121
+ - lib/ace/git_commit/molecules/path_resolver.rb
122
+ - lib/ace/git_commit/molecules/split_commit_executor.rb
123
+ - lib/ace/git_commit/organisms/commit_orchestrator.rb
124
+ - lib/ace/git_commit/version.rb
125
+ homepage: https://github.com/cs3b/ace
126
+ licenses:
127
+ - MIT
128
+ metadata:
129
+ homepage_uri: https://github.com/cs3b/ace
130
+ source_code_uri: https://github.com/cs3b/ace
131
+ changelog_uri: https://github.com/cs3b/ace/blob/main/ace-git-commit/CHANGELOG.md
132
+ rdoc_options: []
133
+ require_paths:
134
+ - lib
135
+ required_ruby_version: !ruby/object:Gem::Requirement
136
+ requirements:
137
+ - - ">="
138
+ - !ruby/object:Gem::Version
139
+ version: 3.2.0
140
+ required_rubygems_version: !ruby/object:Gem::Requirement
141
+ requirements:
142
+ - - ">="
143
+ - !ruby/object:Gem::Version
144
+ version: '0'
145
+ requirements: []
146
+ rubygems_version: 3.6.9
147
+ specification_version: 4
148
+ summary: Intention-aware conventional commit generation from diffs
149
+ test_files: []