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.
- checksums.yaml +7 -0
- data/.ace-defaults/git/commit.yml +22 -0
- data/.ace-defaults/nav/protocols/guide-sources/ace-git-commit.yml +10 -0
- data/.ace-defaults/nav/protocols/prompt-sources/ace-git-commit.yml +19 -0
- data/.ace-defaults/nav/protocols/wfi-sources/ace-git-commit.yml +19 -0
- data/CHANGELOG.md +404 -0
- data/COMPARISON.md +176 -0
- data/LICENSE +21 -0
- data/README.md +44 -0
- data/Rakefile +14 -0
- data/exe/ace-git-commit +13 -0
- data/handbook/guides/version-control-system-message.g.md +507 -0
- data/handbook/prompts/git-commit.md +22 -0
- data/handbook/prompts/git-commit.system.md +150 -0
- data/handbook/skills/as-git-commit/SKILL.md +57 -0
- data/handbook/workflow-instructions/git/commit.wf.md +75 -0
- data/lib/ace/git_commit/atoms/git_executor.rb +62 -0
- data/lib/ace/git_commit/atoms/gitignore_checker.rb +118 -0
- data/lib/ace/git_commit/cli/commands/commit.rb +147 -0
- data/lib/ace/git_commit/cli.rb +23 -0
- data/lib/ace/git_commit/models/commit_group.rb +53 -0
- data/lib/ace/git_commit/models/commit_options.rb +75 -0
- data/lib/ace/git_commit/models/split_commit_result.rb +60 -0
- data/lib/ace/git_commit/models/stage_result.rb +71 -0
- data/lib/ace/git_commit/molecules/commit_grouper.rb +123 -0
- data/lib/ace/git_commit/molecules/commit_summarizer.rb +43 -0
- data/lib/ace/git_commit/molecules/diff_analyzer.rb +111 -0
- data/lib/ace/git_commit/molecules/file_stager.rb +153 -0
- data/lib/ace/git_commit/molecules/message_generator.rb +438 -0
- data/lib/ace/git_commit/molecules/path_resolver.rb +365 -0
- data/lib/ace/git_commit/molecules/split_commit_executor.rb +272 -0
- data/lib/ace/git_commit/organisms/commit_orchestrator.rb +330 -0
- data/lib/ace/git_commit/version.rb +7 -0
- data/lib/ace/git_commit.rb +41 -0
- 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
|