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,365 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module Ace
6
+ module GitCommit
7
+ module Molecules
8
+ # PathResolver handles path resolution and filtering for commits
9
+ class PathResolver
10
+ attr_reader :last_error
11
+
12
+ # Status codes for renames and copies in git porcelain format
13
+ RENAME_STATUS = "R"
14
+ COPY_STATUS = "C"
15
+
16
+ private_constant :RENAME_STATUS, :COPY_STATUS
17
+
18
+ def initialize(git_executor)
19
+ @git = git_executor
20
+ @last_error = nil
21
+ end
22
+
23
+ # Resolve paths to actual file lists
24
+ # Expands directories, glob patterns to files within them
25
+ # @param paths [Array<String>] Paths (files, directories, or glob patterns)
26
+ # @return [Array<String>] List of files
27
+ def resolve_paths(paths)
28
+ return [] if paths.nil? || paths.empty?
29
+
30
+ resolved = []
31
+ paths.each do |path|
32
+ if glob_pattern?(path)
33
+ # Expand glob pattern to matching tracked files
34
+ files = expand_glob_pattern(path)
35
+ resolved.concat(files)
36
+ elsif File.directory?(path)
37
+ # Get all tracked files in directory
38
+ files = files_in_path(path)
39
+ resolved.concat(files)
40
+ elsif File.exist?(path)
41
+ # Single file
42
+ resolved << path
43
+ else
44
+ # Path doesn't exist - we'll validate later
45
+ resolved << path
46
+ end
47
+ end
48
+
49
+ resolved.uniq.sort
50
+ end
51
+
52
+ # Filter files to only those within specified paths
53
+ # @param all_files [Array<String>] All files
54
+ # @param allowed_paths [Array<String>] Allowed paths
55
+ # @return [Array<String>] Filtered files
56
+ def filter_by_paths(all_files, allowed_paths)
57
+ return all_files if allowed_paths.nil? || allowed_paths.empty?
58
+
59
+ all_files.select do |file|
60
+ allowed_paths.any? { |path| file_in_path?(file, path) }
61
+ end
62
+ end
63
+
64
+ # Get all tracked files within specified path
65
+ # @param path [String] Path to search
66
+ # @return [Array<String>] List of tracked files
67
+ def files_in_path(path)
68
+ result = @git.execute("ls-files", path)
69
+ result.strip.split("\n").reject(&:empty?)
70
+ rescue GitError => e
71
+ @last_error = e.message
72
+ []
73
+ end
74
+
75
+ # Get modified files within specified paths
76
+ # @param paths [Array<String>] Paths to check
77
+ # @param staged [Boolean] Check staged or unstaged changes
78
+ # @return [Array<String>] Modified files
79
+ def modified_files_in_paths(paths, staged: false)
80
+ return [] if paths.nil? || paths.empty?
81
+
82
+ args = ["diff", "--name-only"]
83
+ args << "--cached" if staged
84
+
85
+ modified = []
86
+ paths.each do |path|
87
+ result = @git.execute(*args, path)
88
+ files = result.strip.split("\n").reject(&:empty?)
89
+ modified.concat(files)
90
+ end
91
+
92
+ modified.uniq.sort
93
+ rescue GitError => e
94
+ @last_error = e.message
95
+ []
96
+ end
97
+
98
+ # Validate that paths exist or have git changes (deleted/renamed files)
99
+ # @param paths [Array<String>] Paths to validate
100
+ # @return [Hash] Validation result with :valid and :invalid paths
101
+ def validate_paths(paths)
102
+ return {valid: [], invalid: []} if paths.nil? || paths.empty?
103
+
104
+ valid = []
105
+ invalid = []
106
+ git_changed_paths = nil # Lazy load
107
+ git_changed_set = nil # Pre-computed Set for O(1) lookups
108
+
109
+ paths.each do |path|
110
+ if File.exist?(path)
111
+ valid << path
112
+ else
113
+ # Check if path has git changes (deleted, renamed)
114
+ git_changed_paths ||= paths_with_git_changes
115
+ git_changed_set ||= git_changed_paths.map { |p| p.chomp("/") }.to_set
116
+ if path_has_git_changes?(path, git_changed_paths, git_changed_set)
117
+ valid << path
118
+ else
119
+ invalid << path
120
+ end
121
+ end
122
+ end
123
+
124
+ {valid: valid, invalid: invalid}
125
+ end
126
+
127
+ # Check if all paths exist or have git changes (deleted/renamed files)
128
+ # @param paths [Array<String>] Paths to check
129
+ # @return [Boolean] True if all paths are valid
130
+ def all_paths_exist?(paths)
131
+ return true if paths.nil? || paths.empty?
132
+ result = validate_paths(paths)
133
+ result[:invalid].empty?
134
+ end
135
+
136
+ # Check if path contains glob pattern characters
137
+ # @param path [String] Path to check
138
+ # @return [Boolean] True if path is a glob pattern
139
+ def glob_pattern?(path)
140
+ # Check for common glob metacharacters
141
+ path.include?("*") || path.include?("?") || path.include?("[") || path.include?("{")
142
+ end
143
+
144
+ # Check if pattern is a simple (non-recursive) glob pattern
145
+ # Simple globs like *.rb only match at current directory level
146
+ # @param pattern [String] Pattern to check
147
+ # @return [Boolean] True if pattern is a simple glob (not recursive)
148
+ def simple_glob_pattern?(pattern)
149
+ glob_pattern?(pattern) && !pattern.include?("**")
150
+ end
151
+
152
+ # Suggest a recursive alternative for simple glob patterns
153
+ # @param pattern [String] Pattern to analyze
154
+ # @return [String, nil] Suggested recursive pattern, or nil if not applicable
155
+ def suggest_recursive_pattern(pattern)
156
+ return nil unless simple_glob_pattern?(pattern)
157
+
158
+ # For patterns starting with *, prepend **/ for recursive matching
159
+ # e.g., "*.rb" -> "**/*.rb"
160
+ return "**/#{pattern}" if pattern.start_with?("*")
161
+
162
+ # For patterns with subdirectory like "dir/*.rb", insert **/ before the glob part
163
+ # e.g., "dir/*.rb" -> "dir/**/*.rb"
164
+ if pattern.include?("/")
165
+ # Find the last directory separator before the glob portion
166
+ last_slash = pattern.rindex("/")
167
+ dir_part = pattern[0..last_slash]
168
+ glob_part = pattern[(last_slash + 1)..]
169
+ return "#{dir_part}**/#{glob_part}" if glob_part.include?("*")
170
+ end
171
+
172
+ nil
173
+ end
174
+
175
+ # Check if path is within repository boundaries
176
+ # @param path [String] Path to check
177
+ # @return [Boolean] True if path is within repository
178
+ def within_repository?(path)
179
+ return false unless File.exist?(path)
180
+
181
+ expanded = File.expand_path(path)
182
+ repo_root = @git.execute("rev-parse", "--show-toplevel").strip
183
+ expanded.start_with?(File.expand_path(repo_root))
184
+ rescue GitError => e
185
+ @last_error = e.message
186
+ false
187
+ end
188
+
189
+ private
190
+
191
+ # Expand glob pattern to matching committable files
192
+ # Uses Dir.glob for filesystem matching, then filters by git-tracked AND untracked files
193
+ # This includes both tracked files and new untracked files (excludes gitignored)
194
+ # @param pattern [String] Glob pattern
195
+ # @return [Array<String>] List of matching committable files
196
+ def expand_glob_pattern(pattern)
197
+ # First, expand glob pattern on filesystem
198
+ filesystem_matches = Dir.glob(pattern, File::FNM_PATHNAME | File::FNM_DOTMATCH)
199
+
200
+ # Remove . and .. entries
201
+ filesystem_matches.reject! { |f| f.end_with?("/.", "/..") }
202
+
203
+ # Filter to only files (exclude directories)
204
+ filesystem_matches.select! { |f| File.file?(f) }
205
+
206
+ # Get all committable files (tracked + untracked, excluding gitignored)
207
+ committable_files = get_committable_files
208
+
209
+ # Return intersection: files that match glob AND are committable
210
+ filesystem_matches & committable_files
211
+ rescue => e
212
+ @last_error = "Failed to expand glob pattern '#{pattern}': #{e.message}"
213
+ []
214
+ end
215
+
216
+ # Get all tracked files in the repository, with caching
217
+ # @return [Array<String>] List of all git-tracked files
218
+ def get_all_tracked_files
219
+ return @tracked_files if @tracked_files
220
+
221
+ result = @git.execute("ls-files")
222
+ @tracked_files = result.strip.split("\n").reject(&:empty?)
223
+ rescue GitError => e
224
+ @last_error = e.message
225
+ @tracked_files = [] # Cache empty array on error
226
+ end
227
+
228
+ # Get untracked files (not ignored), with caching
229
+ # @return [Array<String>] List of untracked files
230
+ def get_untracked_files
231
+ return @untracked_files if @untracked_files
232
+
233
+ result = @git.execute("ls-files", "--others", "--exclude-standard")
234
+ @untracked_files = result.strip.split("\n").reject(&:empty?)
235
+ rescue GitError => e
236
+ @last_error = e.message
237
+ @untracked_files = [] # Cache empty array on error
238
+ end
239
+
240
+ # Get all files that can be committed (tracked + untracked)
241
+ # This includes tracked files with changes AND new untracked files
242
+ # @return [Array<String>] List of committable files
243
+ def get_committable_files
244
+ tracked = get_all_tracked_files
245
+ untracked = get_untracked_files
246
+ (tracked + untracked).uniq
247
+ end
248
+
249
+ # Get list of paths with git changes (deleted, renamed, modified)
250
+ # Uses -z flag for NUL-terminated output to avoid quoting issues
251
+ # Memoized to avoid multiple git status calls within the same resolver instance
252
+ # @return [Array<String>] List of paths with git changes
253
+ def paths_with_git_changes
254
+ return @git_changed_paths if @git_changed_paths
255
+
256
+ result = @git.execute("status", "--porcelain", "-z")
257
+ @git_changed_paths = parse_porcelain_z_output(result)
258
+ rescue GitError => e
259
+ @last_error = e.message
260
+ @git_changed_paths = [] # Cache empty array on error
261
+ end
262
+
263
+ # Parse NUL-terminated porcelain format output
264
+ # Format: XY path\0 or for renames/copies: XY old_path\0new_path\0
265
+ # @param output [String] Raw git status --porcelain -z output
266
+ # @return [Array<String>] List of paths
267
+ def parse_porcelain_z_output(output)
268
+ paths = []
269
+ entries = output.split("\0")
270
+
271
+ i = 0
272
+ while i < entries.length
273
+ entry = entries[i]
274
+ i += 1
275
+ next if entry.empty?
276
+
277
+ # Status is first 2 chars, then space, then path
278
+ status = entry[0..1]
279
+ path = entry[3..-1]
280
+
281
+ # Check for rename (R) or copy (C) status - next entry is the new path
282
+ if status.include?(RENAME_STATUS) || status.include?(COPY_STATUS)
283
+ paths << path # Old path
284
+ paths << entries[i] if i < entries.length # New path
285
+ i += 1
286
+ elsif path
287
+ paths << path
288
+ end
289
+ end
290
+ paths.compact
291
+ end
292
+
293
+ # Check if a path has git changes
294
+ # @param path [String] Path to check
295
+ # @param git_changed_paths [Array<String>] List of paths with git changes
296
+ # @param changed_set [Set<String>] Pre-computed Set of normalized paths for O(1) lookups
297
+ # @return [Boolean] True if path has git changes
298
+ def path_has_git_changes?(path, git_changed_paths, changed_set)
299
+ # Normalize and strip trailing slashes for consistent comparison
300
+ normalized = normalize_to_repo_relative(path).chomp("/")
301
+
302
+ # Check exact match first (O(1))
303
+ return true if changed_set.include?(normalized)
304
+
305
+ # Check if any changed path is within this directory
306
+ git_changed_paths.any? do |changed|
307
+ changed.chomp("/").start_with?("#{normalized}/")
308
+ end
309
+ end
310
+
311
+ # Normalize path to repo-relative form
312
+ # Handles: ./path, absolute paths, trailing slashes
313
+ # @param path [String] Path to normalize
314
+ # @return [String] Repo-relative path
315
+ def normalize_to_repo_relative(path)
316
+ # Remove trailing slashes for normalization
317
+ clean = path.chomp("/")
318
+
319
+ # Remove leading ./ prefix
320
+ clean = clean.sub(%r{^\./}, "")
321
+
322
+ # Handle absolute paths by making them relative to repo root
323
+ if clean.start_with?("/")
324
+ repo_root = fetch_repo_root
325
+ if repo_root
326
+ if clean == repo_root
327
+ # Exact repo root match returns '.' for current directory
328
+ clean = "."
329
+ elsif clean.start_with?("#{repo_root}/")
330
+ clean = clean.sub("#{repo_root}/", "")
331
+ end
332
+ end
333
+ end
334
+
335
+ clean
336
+ end
337
+
338
+ # Fetch and memoize repository root path
339
+ # @return [String, nil] Repository root path or nil on error
340
+ def fetch_repo_root
341
+ return @repo_root if defined?(@repo_root)
342
+ @repo_root = @git.execute("rev-parse", "--show-toplevel").strip
343
+ rescue GitError
344
+ @repo_root = nil
345
+ end
346
+
347
+ # Check if file is within path
348
+ # @param file [String] File path
349
+ # @param path [String] Directory or file path
350
+ # @return [Boolean] True if file is in path
351
+ def file_in_path?(file, path)
352
+ # Handle exact file match
353
+ return true if file == path
354
+
355
+ # Normalize path by removing trailing slash for comparison
356
+ normalized_path = path.chomp("/")
357
+
358
+ # Use Pathname for robust directory checking
359
+ # Ascend through file's directory hierarchy and check for path match
360
+ Pathname.new(file).ascend.any? { |p| p.to_s == normalized_path }
361
+ end
362
+ end
363
+ end
364
+ end
365
+ end
@@ -0,0 +1,272 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module GitCommit
5
+ module Molecules
6
+ # SplitCommitExecutor performs sequential commits with rollback support
7
+ class SplitCommitExecutor
8
+ # Reference the default scope name constant
9
+ DEFAULT_SCOPE_NAME = Ace::Support::Config::Models::ConfigGroup::DEFAULT_SCOPE_NAME
10
+ def initialize(git_executor:, diff_analyzer:, file_stager:, message_generator:)
11
+ @git = git_executor
12
+ @diff_analyzer = diff_analyzer
13
+ @file_stager = file_stager
14
+ @message_generator = message_generator
15
+ end
16
+
17
+ # Execute split commits
18
+ # @param groups [Array<Models::CommitGroup>] Commit groups
19
+ # @param options [Models::CommitOptions] Options
20
+ # @return [Models::SplitCommitResult] Result
21
+ def execute(groups, options)
22
+ original_head = current_head
23
+ result = Models::SplitCommitResult.new(original_head: original_head)
24
+
25
+ # Pre-generate all messages in batch if using LLM (includes ordering)
26
+ if options.use_llm?
27
+ batch_result = generate_batch_messages(groups, options)
28
+ ordered_groups, messages = reorder_groups_by_llm(groups, batch_result)
29
+ else
30
+ ordered_groups = groups
31
+ messages = Array.new(groups.length) { options.message }
32
+ end
33
+
34
+ ordered_groups.each_with_index do |group, index|
35
+ label = group.scope_name.to_s.empty? ? DEFAULT_SCOPE_NAME : group.scope_name
36
+ puts "[#{index + 1}/#{ordered_groups.length}] Committing #{label} changes..." unless options.quiet
37
+
38
+ unless @file_stager.stage_paths(group.files, quiet: options.quiet)
39
+ error_msg = @file_stager.last_error || "Failed to stage files"
40
+ result.add_failure(group, error_msg)
41
+ unless options.quiet
42
+ warn "✗ Failed to stage files for scope '#{label}':"
43
+ warn " #{error_msg}"
44
+ end
45
+ rollback_to(original_head, result, options)
46
+ return result
47
+ end
48
+
49
+ # Check if all files were gitignored - skip commit for this group
50
+ if @file_stager.all_files_skipped?
51
+ unless options.quiet
52
+ puts "✓ No files to commit for scope '#{label}' (all gitignored)"
53
+ end
54
+ result.add_skipped(group, "All files gitignored")
55
+ next
56
+ end
57
+
58
+ message = messages[index]
59
+
60
+ if options.dry_run
61
+ show_group_dry_run(message, group)
62
+ result.add_dry_run(group)
63
+ next
64
+ end
65
+
66
+ commit_sha = perform_commit(message, options)
67
+ result.add_success(group, commit_sha)
68
+ rescue GitError => e
69
+ scope_label = group.scope_name.to_s.empty? ? DEFAULT_SCOPE_NAME : group.scope_name
70
+ error_msg = "Failed to commit scope '#{scope_label}': #{e.message}"
71
+ result.add_failure(group, error_msg)
72
+ unless options.quiet
73
+ warn "✗ #{error_msg}"
74
+ end
75
+ rollback_to(original_head, result, options)
76
+ return result
77
+ end
78
+
79
+ result
80
+ end
81
+
82
+ private
83
+
84
+ def generate_batch_messages(groups, options)
85
+ puts "Generating commit messages for #{groups.length} scopes..." unless options.quiet
86
+
87
+ # Collect context for all groups using read-only diffs
88
+ # IMPORTANT: We use get_all_diff with file paths instead of staging files
89
+ # This preserves any user-selected hunks from partial staging (git add -p)
90
+ groups_context = groups.map do |group|
91
+ # Get diff for specific files without modifying the index
92
+ diff = @diff_analyzer.get_all_diff(group.files)
93
+ files = group.files
94
+
95
+ # Extract config values using normalized access
96
+ config = normalize_config_keys(group.config)
97
+ {
98
+ scope_name: group.scope_name,
99
+ diff: diff,
100
+ files: files,
101
+ type_hint: config["type_hint"],
102
+ description: config["description"],
103
+ model: config["model"]
104
+ }
105
+ end
106
+
107
+ # Check if all groups use the same model (can batch) or need segmentation
108
+ models_used = groups_context.map { |ctx| ctx[:model] }.compact.uniq
109
+ cli_model = options.model
110
+
111
+ if cli_model
112
+ # CLI flag overrides all group models - can batch
113
+ config_override = {"model" => cli_model}
114
+ return @message_generator.generate_batch(
115
+ groups_context,
116
+ intention: options.intention,
117
+ config: config_override
118
+ )
119
+ elsif models_used.length <= 1
120
+ # All groups use same model (or no model) - can batch
121
+ config_override = models_used.first ? {"model" => models_used.first} : {}
122
+ return @message_generator.generate_batch(
123
+ groups_context,
124
+ intention: options.intention,
125
+ config: config_override
126
+ )
127
+ end
128
+
129
+ # Different models per scope - generate sequentially by model
130
+ generate_segmented_by_model(groups_context, options.intention)
131
+ rescue Error => e
132
+ warn "[ace-git-commit] Batch generation failed, falling back to per-scope generation: #{e.message}" unless options.quiet
133
+ generate_per_scope_messages(groups_context, options)
134
+ end
135
+
136
+ # Generate messages segmented by model when groups have different model configs
137
+ def generate_segmented_by_model(groups_context, intention)
138
+ messages_by_scope = {}
139
+
140
+ # Group contexts by their model
141
+ by_model = groups_context.group_by { |ctx| ctx[:model] || "default" }
142
+
143
+ by_model.each do |model, contexts|
144
+ config_override = (model == "default") ? {} : {"model" => model}
145
+ result = @message_generator.generate_batch(
146
+ contexts,
147
+ intention: intention,
148
+ config: config_override
149
+ )
150
+
151
+ # Map messages back to scope names
152
+ result[:order].each_with_index do |scope, idx|
153
+ messages_by_scope[scope] = result[:messages][idx]
154
+ end
155
+ end
156
+
157
+ # Rebuild in original order
158
+ ordered_messages = groups_context.map { |ctx| messages_by_scope[ctx[:scope_name]] }
159
+ {messages: ordered_messages, order: groups_context.map { |ctx| ctx[:scope_name] }}
160
+ end
161
+
162
+ # Normalize config keys to strings for consistent access
163
+ def normalize_config_keys(config)
164
+ return {} unless config.is_a?(Hash)
165
+
166
+ config.transform_keys(&:to_s)
167
+ end
168
+
169
+ def reorder_groups_by_llm(groups, batch_result)
170
+ messages = batch_result[:messages]
171
+ order = batch_result[:order]
172
+
173
+ # Align groups with messages using the order array
174
+ # batch_result[:order] contains scope names matching batch_result[:messages]
175
+ groups_by_scope = groups.to_h { |g| [g.scope_name, g] }
176
+ aligned_groups = order.map { |scope| groups_by_scope[scope] }.compact
177
+
178
+ # Handle any groups that weren't in the LLM response (fallback)
179
+ missing_groups = groups.reject { |g| order.include?(g.scope_name) }
180
+ aligned_groups.concat(missing_groups)
181
+
182
+ # Extend messages array for missing groups by mapping from scope->message.
183
+ message_by_scope = order.each_with_index.to_h { |scope, idx| [scope, messages[idx]] }
184
+ aligned_messages = aligned_groups.map { |g| message_by_scope[g.scope_name] }
185
+ if aligned_messages.any?(&:nil?)
186
+ missing = aligned_groups.each_with_index.filter_map { |g, idx| g.scope_name if aligned_messages[idx].nil? }
187
+ raise Error, "Missing generated message(s) for scope(s): #{missing.join(", ")}"
188
+ end
189
+
190
+ # Always sort by commit type - more reliable than LLM ordering
191
+ sort_by_commit_type(aligned_groups, aligned_messages)
192
+ end
193
+
194
+ def generate_per_scope_messages(groups_context, options)
195
+ messages = groups_context.map do |ctx|
196
+ config = {}
197
+ config["model"] = options.model if options.model
198
+ config["model"] = ctx[:model] if options.model.nil? && ctx[:model]
199
+
200
+ @message_generator.generate(
201
+ ctx[:diff],
202
+ intention: options.intention,
203
+ files: ctx[:files],
204
+ config: config
205
+ )
206
+ end
207
+
208
+ {messages: messages, order: groups_context.map { |ctx| ctx[:scope_name] }}
209
+ end
210
+
211
+ def sort_by_commit_type(groups, messages)
212
+ # Type priority: feat/fix first, then refactor/test, then chore, then docs last
213
+ type_priority = {
214
+ "feat" => 0, "fix" => 1, "refactor" => 2, "test" => 3,
215
+ "perf" => 4, "chore" => 5, "style" => 6, "docs" => 7
216
+ }
217
+
218
+ # Pair groups with messages and extract types
219
+ pairs = groups.zip(messages).map do |group, msg|
220
+ type = msg.to_s.match(/^(\w+)[(:]/)&.[](1) || "chore"
221
+ {group: group, message: msg, type: type, priority: type_priority[type] || 5}
222
+ end
223
+
224
+ # Sort by priority
225
+ sorted = pairs.sort_by { |p| [p[:priority], p[:group].scope_name] }
226
+
227
+ [sorted.map { |p| p[:group] }, sorted.map { |p| p[:message] }]
228
+ end
229
+
230
+ def show_group_dry_run(message, group)
231
+ puts "-" * 40
232
+ puts "Scope: #{group.scope_name}"
233
+ puts "Files:"
234
+ group.files.each { |file| puts " #{file}" }
235
+ puts "\nMessage:"
236
+ puts message
237
+ puts "-" * 40
238
+ end
239
+
240
+ def perform_commit(message, options)
241
+ puts "Committing..." unless options.quiet
242
+ @git.execute("commit", "-m", message)
243
+ commit_sha = @git.execute("rev-parse", "HEAD").strip
244
+
245
+ unless options.quiet
246
+ summarizer = Molecules::CommitSummarizer.new(@git)
247
+ summary = summarizer.summarize(commit_sha)
248
+ puts summary
249
+ end
250
+
251
+ commit_sha
252
+ end
253
+
254
+ def current_head
255
+ @git.execute("rev-parse", "HEAD").strip
256
+ rescue GitError
257
+ nil
258
+ end
259
+
260
+ def rollback_to(original_head, result, options)
261
+ return if original_head.nil?
262
+
263
+ @git.execute("reset", "--soft", original_head)
264
+ puts "Rolled back split commit operation." unless options.quiet
265
+ rescue GitError => e
266
+ result.mark_rollback_error(e.message)
267
+ warn "Rollback failed: #{e.message}"
268
+ end
269
+ end
270
+ end
271
+ end
272
+ end