ace-git 0.18.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/config.yml +83 -0
- data/.ace-defaults/nav/protocols/guide-sources/ace-git.yml +10 -0
- data/.ace-defaults/nav/protocols/tmpl-sources/ace-git.yml +19 -0
- data/.ace-defaults/nav/protocols/wfi-sources/ace-git.yml +19 -0
- data/CHANGELOG.md +762 -0
- data/LICENSE +21 -0
- data/README.md +48 -0
- data/Rakefile +14 -0
- data/docs/demo/ace-git-getting-started.gif +0 -0
- data/docs/demo/ace-git-getting-started.tape.yml +18 -0
- data/docs/demo/fixtures/README.md +3 -0
- data/docs/demo/fixtures/sample.txt +1 -0
- data/docs/getting-started.md +87 -0
- data/docs/handbook.md +50 -0
- data/docs/usage.md +259 -0
- data/exe/ace-git +37 -0
- data/handbook/guides/version-control/ruby.md +41 -0
- data/handbook/guides/version-control/rust.md +49 -0
- data/handbook/guides/version-control/typescript.md +47 -0
- data/handbook/guides/version-control-system-git.g.md +829 -0
- data/handbook/skills/as-git-rebase/SKILL.md +43 -0
- data/handbook/skills/as-git-reorganize-commits/SKILL.md +41 -0
- data/handbook/skills/as-github-pr-create/SKILL.md +60 -0
- data/handbook/skills/as-github-pr-update/SKILL.md +41 -0
- data/handbook/skills/as-github-release-publish/SKILL.md +58 -0
- data/handbook/templates/commit/squash.template.md +59 -0
- data/handbook/templates/pr/bugfix.template.md +103 -0
- data/handbook/templates/pr/default.template.md +40 -0
- data/handbook/templates/pr/feature.template.md +41 -0
- data/handbook/workflow-instructions/git/rebase.wf.md +402 -0
- data/handbook/workflow-instructions/git/reorganize-commits.wf.md +158 -0
- data/handbook/workflow-instructions/github/pr/create.wf.md +282 -0
- data/handbook/workflow-instructions/github/pr/update.wf.md +199 -0
- data/handbook/workflow-instructions/github/release-publish.wf.md +162 -0
- data/lib/ace/git/atoms/command_executor.rb +253 -0
- data/lib/ace/git/atoms/date_resolver.rb +129 -0
- data/lib/ace/git/atoms/diff_numstat_parser.rb +82 -0
- data/lib/ace/git/atoms/diff_parser.rb +110 -0
- data/lib/ace/git/atoms/file_grouper.rb +152 -0
- data/lib/ace/git/atoms/git_scope_filter.rb +86 -0
- data/lib/ace/git/atoms/git_status_fetcher.rb +29 -0
- data/lib/ace/git/atoms/grouped_stats_formatter.rb +233 -0
- data/lib/ace/git/atoms/lock_error_detector.rb +79 -0
- data/lib/ace/git/atoms/pattern_filter.rb +156 -0
- data/lib/ace/git/atoms/pr_identifier_parser.rb +88 -0
- data/lib/ace/git/atoms/repository_checker.rb +97 -0
- data/lib/ace/git/atoms/repository_state_detector.rb +92 -0
- data/lib/ace/git/atoms/stale_lock_cleaner.rb +247 -0
- data/lib/ace/git/atoms/status_formatter.rb +180 -0
- data/lib/ace/git/atoms/task_pattern_extractor.rb +57 -0
- data/lib/ace/git/atoms/time_formatter.rb +84 -0
- data/lib/ace/git/cli/commands/branch.rb +62 -0
- data/lib/ace/git/cli/commands/diff.rb +252 -0
- data/lib/ace/git/cli/commands/pr.rb +119 -0
- data/lib/ace/git/cli/commands/status.rb +84 -0
- data/lib/ace/git/cli.rb +87 -0
- data/lib/ace/git/models/diff_config.rb +185 -0
- data/lib/ace/git/models/diff_result.rb +94 -0
- data/lib/ace/git/models/repo_status.rb +202 -0
- data/lib/ace/git/molecules/branch_reader.rb +92 -0
- data/lib/ace/git/molecules/config_loader.rb +108 -0
- data/lib/ace/git/molecules/diff_filter.rb +102 -0
- data/lib/ace/git/molecules/diff_generator.rb +160 -0
- data/lib/ace/git/molecules/git_status_fetcher.rb +32 -0
- data/lib/ace/git/molecules/pr_metadata_fetcher.rb +286 -0
- data/lib/ace/git/molecules/recent_commits_fetcher.rb +53 -0
- data/lib/ace/git/organisms/diff_orchestrator.rb +178 -0
- data/lib/ace/git/organisms/repo_status_loader.rb +264 -0
- data/lib/ace/git/version.rb +7 -0
- data/lib/ace/git.rb +230 -0
- metadata +201 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Git
|
|
5
|
+
module Molecules
|
|
6
|
+
# Fetches git status information via system command
|
|
7
|
+
# Uses git status -sb for compact, familiar output
|
|
8
|
+
#
|
|
9
|
+
# Note: Moved from Atoms to Molecules per ATOM architecture -
|
|
10
|
+
# executing system commands is I/O, which belongs in Molecules layer.
|
|
11
|
+
module GitStatusFetcher
|
|
12
|
+
class << self
|
|
13
|
+
# Fetch git status in short branch format
|
|
14
|
+
# @param executor [CommandExecutor] Command executor (default: CommandExecutor)
|
|
15
|
+
# @return [Hash] Result with :success, :output, :error
|
|
16
|
+
def fetch_status_sb(executor: Atoms::CommandExecutor)
|
|
17
|
+
# Disable color to ensure clean output for LLM context
|
|
18
|
+
result = executor.execute("git", "-c", "color.status=false", "status", "-sb")
|
|
19
|
+
|
|
20
|
+
if result[:success]
|
|
21
|
+
{success: true, output: result[:output].strip}
|
|
22
|
+
else
|
|
23
|
+
{success: false, output: "", error: result[:error]}
|
|
24
|
+
end
|
|
25
|
+
rescue => e
|
|
26
|
+
{success: false, output: "", error: e.message}
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Git
|
|
7
|
+
module Molecules
|
|
8
|
+
# Fetch PR metadata via gh CLI
|
|
9
|
+
# Consolidated from ace-bundle GhPrExecutor and ace-review GhPrFetcher
|
|
10
|
+
class PrMetadataFetcher
|
|
11
|
+
# Error message patterns from gh CLI
|
|
12
|
+
PR_NOT_FOUND_PATTERN = /not found|Could not resolve/i
|
|
13
|
+
AUTH_ERROR_PATTERN = /authentication|Unauthorized|not logged in|auth login/i
|
|
14
|
+
|
|
15
|
+
# Valid characters for PR identifiers (owner/repo#number format)
|
|
16
|
+
# Allows: alphanumeric, hyphens, underscores, dots, forward slashes, hash, at, colon
|
|
17
|
+
# This prevents shell metacharacters from reaching command execution
|
|
18
|
+
VALID_IDENTIFIER_PATTERN = /\A[\w\/.\-#@:]+\z/
|
|
19
|
+
|
|
20
|
+
# Fields to fetch for PR metadata
|
|
21
|
+
# Extracted as constant for maintainability
|
|
22
|
+
PR_FIELDS = %w[
|
|
23
|
+
number
|
|
24
|
+
state
|
|
25
|
+
isDraft
|
|
26
|
+
title
|
|
27
|
+
author
|
|
28
|
+
headRefName
|
|
29
|
+
baseRefName
|
|
30
|
+
url
|
|
31
|
+
isCrossRepository
|
|
32
|
+
headRepositoryOwner
|
|
33
|
+
].freeze
|
|
34
|
+
|
|
35
|
+
class << self
|
|
36
|
+
# Check if gh CLI is installed
|
|
37
|
+
# @return [Boolean] True if gh is installed
|
|
38
|
+
def gh_installed?
|
|
39
|
+
result = Atoms::CommandExecutor.execute("gh", "--version")
|
|
40
|
+
result[:success]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Check if gh CLI is authenticated
|
|
44
|
+
# @return [Boolean] True if authenticated
|
|
45
|
+
def gh_authenticated?
|
|
46
|
+
result = Atoms::CommandExecutor.execute("gh", "auth", "status")
|
|
47
|
+
result[:success]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Fetch PR diff content
|
|
51
|
+
# @param identifier [String] PR identifier (number, URL, or owner/repo#number)
|
|
52
|
+
# @param timeout [Integer] Timeout in seconds (default from config)
|
|
53
|
+
# @return [Hash] Result with :success, :diff, :error
|
|
54
|
+
def fetch_diff(identifier, timeout: Ace::Git.network_timeout)
|
|
55
|
+
parsed = Atoms::PrIdentifierParser.parse(identifier)
|
|
56
|
+
raise ArgumentError, "Invalid PR identifier: #{identifier}" if parsed.nil?
|
|
57
|
+
|
|
58
|
+
# Validate identifier characters before command execution (defense in depth)
|
|
59
|
+
validate_identifier_characters(parsed.gh_format)
|
|
60
|
+
|
|
61
|
+
result = execute_gh_command(["gh", "pr", "diff", parsed.gh_format], timeout: timeout)
|
|
62
|
+
|
|
63
|
+
if result[:success]
|
|
64
|
+
{
|
|
65
|
+
success: true,
|
|
66
|
+
diff: result[:output],
|
|
67
|
+
identifier: parsed.gh_format,
|
|
68
|
+
source: build_source_label(parsed)
|
|
69
|
+
}
|
|
70
|
+
else
|
|
71
|
+
handle_error(result[:error], parsed.gh_format)
|
|
72
|
+
end
|
|
73
|
+
rescue Errno::ENOENT
|
|
74
|
+
raise Ace::Git::GhNotInstalledError, "GitHub CLI (gh) not installed. Install with: brew install gh"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Fetch PR metadata (state, draft status, title, etc.)
|
|
78
|
+
# @param identifier [String] PR identifier
|
|
79
|
+
# @param timeout [Integer] Timeout in seconds (default from config)
|
|
80
|
+
# @return [Hash] Result with :success, :metadata, :error
|
|
81
|
+
def fetch_metadata(identifier, timeout: Ace::Git.network_timeout)
|
|
82
|
+
parsed = Atoms::PrIdentifierParser.parse(identifier)
|
|
83
|
+
raise ArgumentError, "Invalid PR identifier: #{identifier}" if parsed.nil?
|
|
84
|
+
|
|
85
|
+
# Validate identifier characters before command execution (defense in depth)
|
|
86
|
+
validate_identifier_characters(parsed.gh_format)
|
|
87
|
+
|
|
88
|
+
result = execute_gh_command(
|
|
89
|
+
["gh", "pr", "view", parsed.gh_format, "--json", PR_FIELDS.join(",")],
|
|
90
|
+
timeout: timeout
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
if result[:success]
|
|
94
|
+
metadata = JSON.parse(result[:output])
|
|
95
|
+
{
|
|
96
|
+
success: true,
|
|
97
|
+
metadata: metadata,
|
|
98
|
+
identifier: parsed.gh_format,
|
|
99
|
+
parsed: {number: parsed.number, repo: parsed.repo}
|
|
100
|
+
}
|
|
101
|
+
else
|
|
102
|
+
handle_error(result[:error], parsed.gh_format)
|
|
103
|
+
end
|
|
104
|
+
rescue JSON::ParserError => e
|
|
105
|
+
{
|
|
106
|
+
success: false,
|
|
107
|
+
error: "Failed to parse PR metadata: #{e.message}"
|
|
108
|
+
}
|
|
109
|
+
rescue Errno::ENOENT
|
|
110
|
+
raise Ace::Git::GhNotInstalledError, "GitHub CLI (gh) not installed. Install with: brew install gh"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Fetch both diff and metadata
|
|
114
|
+
# @param identifier [String] PR identifier
|
|
115
|
+
# @param timeout [Integer] Timeout in seconds (default from config)
|
|
116
|
+
# @return [Hash] Result with :success, :diff, :metadata, :error
|
|
117
|
+
def fetch_pr(identifier, timeout: Ace::Git.network_timeout)
|
|
118
|
+
diff_result = fetch_diff(identifier, timeout: timeout)
|
|
119
|
+
return diff_result unless diff_result[:success]
|
|
120
|
+
|
|
121
|
+
metadata_result = fetch_metadata(identifier, timeout: timeout)
|
|
122
|
+
return metadata_result unless metadata_result[:success]
|
|
123
|
+
|
|
124
|
+
{
|
|
125
|
+
success: true,
|
|
126
|
+
diff: diff_result[:diff],
|
|
127
|
+
metadata: metadata_result[:metadata],
|
|
128
|
+
identifier: diff_result[:identifier],
|
|
129
|
+
source: diff_result[:source]
|
|
130
|
+
}
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Find PR number for current branch
|
|
134
|
+
# @param timeout [Integer] Timeout in seconds (default from config)
|
|
135
|
+
# @return [String|nil] PR number or nil
|
|
136
|
+
def find_pr_for_branch(timeout: Ace::Git.network_timeout)
|
|
137
|
+
result = execute_gh_command(
|
|
138
|
+
["gh", "pr", "view", "--json", "number"],
|
|
139
|
+
timeout: timeout
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
return nil unless result[:success]
|
|
143
|
+
|
|
144
|
+
data = JSON.parse(result[:output])
|
|
145
|
+
data["number"]&.to_s
|
|
146
|
+
rescue JSON::ParserError, Errno::ENOENT
|
|
147
|
+
nil
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Fetch recently merged PRs
|
|
151
|
+
# @param limit [Integer] Maximum number of PRs to return (default from config)
|
|
152
|
+
# @param timeout [Integer] Timeout in seconds
|
|
153
|
+
# @return [Hash] Result with :success, :prs array, or :error
|
|
154
|
+
def fetch_recently_merged(limit: Ace::Git.merged_prs_limit, timeout: Ace::Git.network_timeout)
|
|
155
|
+
result = execute_gh_command(
|
|
156
|
+
["gh", "pr", "list", "--state", "merged", "--limit", limit.to_s,
|
|
157
|
+
"--json", "number,title,mergedAt,author"],
|
|
158
|
+
timeout: timeout
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
if result[:success]
|
|
162
|
+
prs = JSON.parse(result[:output])
|
|
163
|
+
{success: true, prs: prs}
|
|
164
|
+
else
|
|
165
|
+
{success: false, error: result[:error], prs: []}
|
|
166
|
+
end
|
|
167
|
+
rescue JSON::ParserError => e
|
|
168
|
+
{success: false, error: "Failed to parse merged PRs: #{e.message}", prs: []}
|
|
169
|
+
rescue Errno::ENOENT
|
|
170
|
+
{success: false, error: "GitHub CLI (gh) not installed", prs: []}
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Fetch open PRs
|
|
174
|
+
# @param exclude_branch [String, nil] Branch name to exclude from results
|
|
175
|
+
# @param limit [Integer] Maximum number of PRs to return (default from config)
|
|
176
|
+
# @param timeout [Integer] Timeout in seconds
|
|
177
|
+
# @return [Hash] Result with :success, :prs array, or :error
|
|
178
|
+
def fetch_open_prs(exclude_branch: nil, limit: Ace::Git.open_prs_limit, timeout: Ace::Git.network_timeout)
|
|
179
|
+
result = execute_gh_command(
|
|
180
|
+
["gh", "pr", "list", "--state", "open", "--limit", limit.to_s,
|
|
181
|
+
"--json", "number,title,author,headRefName"],
|
|
182
|
+
timeout: timeout
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
if result[:success]
|
|
186
|
+
prs = JSON.parse(result[:output])
|
|
187
|
+
# Filter out current branch if specified
|
|
188
|
+
if exclude_branch
|
|
189
|
+
prs = prs.reject { |pr| pr["headRefName"] == exclude_branch }
|
|
190
|
+
end
|
|
191
|
+
{success: true, prs: prs}
|
|
192
|
+
else
|
|
193
|
+
{success: false, error: result[:error], prs: []}
|
|
194
|
+
end
|
|
195
|
+
rescue JSON::ParserError => e
|
|
196
|
+
{success: false, error: "Failed to parse open PRs: #{e.message}", prs: []}
|
|
197
|
+
rescue Errno::ENOENT
|
|
198
|
+
{success: false, error: "GitHub CLI (gh) not installed", prs: []}
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Fetch all recent PRs in a single call for optimal performance
|
|
202
|
+
# Returns open, merged, and closed PRs - caller filters by state locally
|
|
203
|
+
# @param limit [Integer] Maximum PRs to fetch (default: 15, enough for typical use)
|
|
204
|
+
# @param timeout [Integer] Timeout in seconds
|
|
205
|
+
# @return [Hash] Result with :success, :prs array, or :error
|
|
206
|
+
def fetch_all_prs(limit: 15, timeout: Ace::Git.network_timeout)
|
|
207
|
+
result = execute_gh_command(
|
|
208
|
+
["gh", "pr", "list", "--state", "all", "--limit", limit.to_s,
|
|
209
|
+
"--json", "number,title,state,mergedAt,author,headRefName,isDraft,baseRefName,url"],
|
|
210
|
+
timeout: timeout
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
if result[:success]
|
|
214
|
+
prs = JSON.parse(result[:output])
|
|
215
|
+
{success: true, prs: prs}
|
|
216
|
+
else
|
|
217
|
+
{success: false, error: result[:error], prs: []}
|
|
218
|
+
end
|
|
219
|
+
rescue JSON::ParserError => e
|
|
220
|
+
{success: false, error: "Failed to parse PR list: #{e.message}", prs: []}
|
|
221
|
+
rescue Errno::ENOENT
|
|
222
|
+
{success: false, error: "GitHub CLI (gh) not installed", prs: []}
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
private
|
|
226
|
+
|
|
227
|
+
# Environment variables for consistent gh CLI output across all locales
|
|
228
|
+
GH_ENV = {"LC_ALL" => "C"}.freeze
|
|
229
|
+
|
|
230
|
+
# Execute gh command with timeout via CommandExecutor
|
|
231
|
+
# @param args [Array<String>] Command arguments
|
|
232
|
+
# @param timeout [Integer] Timeout in seconds
|
|
233
|
+
# @return [Hash] Result with :success, :output, :error, :exit_code
|
|
234
|
+
def execute_gh_command(args, timeout:)
|
|
235
|
+
# Delegate to CommandExecutor with LC_ALL=C for consistent output format
|
|
236
|
+
# regardless of user's locale settings
|
|
237
|
+
result = Atoms::CommandExecutor.execute(*args, timeout: timeout, env: GH_ENV)
|
|
238
|
+
|
|
239
|
+
# Check for timeout (CommandExecutor returns exit_code: -1 with timeout message)
|
|
240
|
+
if result[:exit_code] == -1 && result[:error]&.include?("timed out")
|
|
241
|
+
raise Ace::Git::TimeoutError, "gh command timed out after #{timeout}s: #{args.join(" ")}"
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
result
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def build_source_label(parsed)
|
|
248
|
+
if parsed.repo
|
|
249
|
+
"pr:#{parsed.repo}##{parsed.number}"
|
|
250
|
+
else
|
|
251
|
+
"pr:#{parsed.number}"
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Validate identifier characters to prevent shell metacharacter injection
|
|
256
|
+
# Defense in depth - Open3.capture3 with array args is already safe,
|
|
257
|
+
# but this adds explicit validation as a secondary security layer
|
|
258
|
+
# @param identifier [String] Identifier to validate
|
|
259
|
+
# @raise [ArgumentError] If identifier contains invalid characters
|
|
260
|
+
def validate_identifier_characters(identifier)
|
|
261
|
+
return if identifier.nil? || identifier.empty?
|
|
262
|
+
|
|
263
|
+
unless identifier.match?(VALID_IDENTIFIER_PATTERN)
|
|
264
|
+
raise ArgumentError, "Invalid identifier characters: #{identifier}"
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def handle_error(error_message, identifier)
|
|
269
|
+
error_str = error_message.to_s
|
|
270
|
+
|
|
271
|
+
if error_str.match?(PR_NOT_FOUND_PATTERN)
|
|
272
|
+
raise Ace::Git::PrNotFoundError, "PR not found: #{identifier}"
|
|
273
|
+
elsif error_str.match?(AUTH_ERROR_PATTERN)
|
|
274
|
+
raise Ace::Git::GhAuthenticationError, "Not authenticated with GitHub. Run: gh auth login"
|
|
275
|
+
else
|
|
276
|
+
{
|
|
277
|
+
success: false,
|
|
278
|
+
error: "gh pr command failed: #{error_str}"
|
|
279
|
+
}
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Git
|
|
5
|
+
module Molecules
|
|
6
|
+
# Fetches recent commits from the repository
|
|
7
|
+
# Returns commit hashes and subjects in oneline format
|
|
8
|
+
module RecentCommitsFetcher
|
|
9
|
+
class << self
|
|
10
|
+
# Fetch recent commits
|
|
11
|
+
# @param limit [Integer] Number of commits to fetch (default: 3)
|
|
12
|
+
# @param executor [CommandExecutor] Command executor
|
|
13
|
+
# @return [Hash] Result with :success, :commits array, :error
|
|
14
|
+
def fetch(limit: 3, executor: Atoms::CommandExecutor)
|
|
15
|
+
return {success: true, commits: []} if limit <= 0
|
|
16
|
+
|
|
17
|
+
result = executor.execute(
|
|
18
|
+
"git", "log",
|
|
19
|
+
"-#{limit}",
|
|
20
|
+
"--format=%h %s"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
if result[:success]
|
|
24
|
+
commits = parse_commits(result[:output])
|
|
25
|
+
{success: true, commits: commits}
|
|
26
|
+
else
|
|
27
|
+
{success: false, commits: [], error: result[:error]}
|
|
28
|
+
end
|
|
29
|
+
rescue => e
|
|
30
|
+
{success: false, commits: [], error: e.message}
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
# Parse git log output into structured array
|
|
36
|
+
# @param output [String] Git log output
|
|
37
|
+
# @return [Array<Hash>] Array of commit hashes with :hash and :subject
|
|
38
|
+
def parse_commits(output)
|
|
39
|
+
return [] if output.nil? || output.empty?
|
|
40
|
+
|
|
41
|
+
output.strip.split("\n").map do |line|
|
|
42
|
+
hash, *subject_parts = line.split(" ")
|
|
43
|
+
{
|
|
44
|
+
hash: hash,
|
|
45
|
+
subject: subject_parts.join(" ")
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Git
|
|
5
|
+
module Organisms
|
|
6
|
+
# Orchestrates the complete diff workflow (NO caching per task decisions)
|
|
7
|
+
# Migrated from ace-git-diff
|
|
8
|
+
class DiffOrchestrator
|
|
9
|
+
class << self
|
|
10
|
+
# Generate diff with full workflow: config -> generate -> filter -> result
|
|
11
|
+
# @param options [Hash] Options for diff generation
|
|
12
|
+
# @option options [String] :since Date or commit to diff from
|
|
13
|
+
# @option options [Array<String>] :ranges Git ranges to diff
|
|
14
|
+
# @option options [Array<String>] :paths Path patterns to include
|
|
15
|
+
# @option options [Array<String>] :exclude_patterns Patterns to exclude
|
|
16
|
+
# @option options [Boolean] :exclude_whitespace Exclude whitespace changes
|
|
17
|
+
# @option options [Boolean] :exclude_renames Exclude renames
|
|
18
|
+
# @option options [Symbol] :format Output format (:diff or :summary)
|
|
19
|
+
# @return [Models::DiffResult] Complete diff result
|
|
20
|
+
def generate(options = {})
|
|
21
|
+
# Load configuration
|
|
22
|
+
config = Molecules::ConfigLoader.load(options)
|
|
23
|
+
|
|
24
|
+
# Short-circuit for grouped_stats — only numstat needed, skip full diff
|
|
25
|
+
if config.format == :grouped_stats
|
|
26
|
+
return build_grouped_stats_result(config, nil, filtered: !config.exclude_patterns.empty?)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Generate raw diff
|
|
30
|
+
raw_diff = Molecules::DiffGenerator.generate(config)
|
|
31
|
+
|
|
32
|
+
# Filter diff
|
|
33
|
+
filtered_diff = Molecules::DiffFilter.filter(raw_diff, config)
|
|
34
|
+
|
|
35
|
+
# Parse and create result
|
|
36
|
+
parsed = Atoms::DiffParser.parse(filtered_diff)
|
|
37
|
+
|
|
38
|
+
Models::DiffResult.from_parsed(
|
|
39
|
+
parsed,
|
|
40
|
+
metadata: {
|
|
41
|
+
config: config.to_h,
|
|
42
|
+
generated_at: Time.now.iso8601,
|
|
43
|
+
filtered: !config.exclude_patterns.empty?
|
|
44
|
+
},
|
|
45
|
+
filtered: !config.exclude_patterns.empty?
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Generate diff from configuration hash
|
|
50
|
+
# @param config_hash [Hash] Configuration from YAML or other source
|
|
51
|
+
# @return [Models::DiffResult] Complete diff result
|
|
52
|
+
def from_config(config_hash)
|
|
53
|
+
diff_config = Molecules::ConfigLoader.extract_diff_config(config_hash)
|
|
54
|
+
generate(diff_config)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Generate diff for a specific range
|
|
58
|
+
# @param range [String] Git range (e.g., "HEAD~5..HEAD")
|
|
59
|
+
# @param options [Hash] Additional options
|
|
60
|
+
# @return [Models::DiffResult] Complete diff result
|
|
61
|
+
def for_range(range, options = {})
|
|
62
|
+
generate(options.merge(ranges: [range]))
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Generate diff since a date or commit
|
|
66
|
+
# @param since [String] Date or commit reference
|
|
67
|
+
# @param options [Hash] Additional options
|
|
68
|
+
# @return [Models::DiffResult] Complete diff result
|
|
69
|
+
def since(since, options = {})
|
|
70
|
+
generate(options.merge(since: since))
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Generate staged diff
|
|
74
|
+
# @param options [Hash] Additional options
|
|
75
|
+
# @return [Models::DiffResult] Complete diff result
|
|
76
|
+
def staged(options = {})
|
|
77
|
+
generate(options.merge(format: :staged))
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Generate working directory diff
|
|
81
|
+
# @param options [Hash] Additional options
|
|
82
|
+
# @return [Models::DiffResult] Complete diff result
|
|
83
|
+
def working(options = {})
|
|
84
|
+
generate(options.merge(format: :working))
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Generate diff with smart defaults (based on git state)
|
|
88
|
+
# @param options [Hash] Additional options
|
|
89
|
+
# @return [Models::DiffResult] Complete diff result
|
|
90
|
+
def smart(options = {})
|
|
91
|
+
# Use empty config to trigger smart default behavior in DiffGenerator
|
|
92
|
+
generate(options)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Generate raw (unfiltered) diff
|
|
96
|
+
# @param options [Hash] Options for diff generation
|
|
97
|
+
# @return [Models::DiffResult] Unfiltered diff result
|
|
98
|
+
def raw(options = {})
|
|
99
|
+
# Temporarily override exclude patterns to be empty
|
|
100
|
+
options_with_no_filtering = options.merge(exclude_patterns: [])
|
|
101
|
+
generate(options_with_no_filtering)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Generate diff and save to file
|
|
105
|
+
# @param output_path [String] Path to save the diff
|
|
106
|
+
# @param options [Hash] Options for diff generation
|
|
107
|
+
# @return [String] Path to the saved file
|
|
108
|
+
def save_to_file(output_path, options = {})
|
|
109
|
+
result = generate(options)
|
|
110
|
+
|
|
111
|
+
# Create parent directories if needed
|
|
112
|
+
require "fileutils"
|
|
113
|
+
FileUtils.mkdir_p(File.dirname(output_path)) unless File.dirname(output_path) == "."
|
|
114
|
+
|
|
115
|
+
# Write content to file
|
|
116
|
+
File.write(output_path, result.content)
|
|
117
|
+
|
|
118
|
+
output_path
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Generate diff and save to file (with explicit format)
|
|
122
|
+
# @param output_path [String] Path to save the diff
|
|
123
|
+
# @param format [Symbol] Format (:diff or :summary)
|
|
124
|
+
# @param options [Hash] Options for diff generation
|
|
125
|
+
# @return [String] Path to the saved file
|
|
126
|
+
def save_with_format(output_path, format: :diff, **options)
|
|
127
|
+
options_with_format = options.merge(format: format)
|
|
128
|
+
save_to_file(output_path, options_with_format)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
private
|
|
132
|
+
|
|
133
|
+
def build_grouped_stats_result(config, parsed_diff, filtered:)
|
|
134
|
+
numstat_output = Molecules::DiffGenerator.generate_numstat(config)
|
|
135
|
+
entries = Atoms::DiffNumstatParser.parse(numstat_output)
|
|
136
|
+
filtered_entries = filter_entries(entries, config.exclude_patterns)
|
|
137
|
+
grouped = Atoms::FileGrouper.group(
|
|
138
|
+
filtered_entries,
|
|
139
|
+
layers: config.grouped_stats_layers,
|
|
140
|
+
dotfile_groups: config.grouped_stats_dotfile_groups
|
|
141
|
+
)
|
|
142
|
+
content = Atoms::GroupedStatsFormatter.format(grouped, collapse_above: config.grouped_stats_collapse_above)
|
|
143
|
+
totals = grouped[:total]
|
|
144
|
+
|
|
145
|
+
Models::DiffResult.new(
|
|
146
|
+
content: content,
|
|
147
|
+
stats: {
|
|
148
|
+
additions: totals[:additions],
|
|
149
|
+
deletions: totals[:deletions],
|
|
150
|
+
files: totals[:files],
|
|
151
|
+
total_changes: totals[:additions] + totals[:deletions],
|
|
152
|
+
line_count: parsed_diff&.[](:line_count)
|
|
153
|
+
},
|
|
154
|
+
files: grouped[:files],
|
|
155
|
+
metadata: {
|
|
156
|
+
config: config.to_h,
|
|
157
|
+
generated_at: Time.now.iso8601,
|
|
158
|
+
filtered: filtered,
|
|
159
|
+
grouped_stats: grouped.merge(collapse_above: config.grouped_stats_collapse_above)
|
|
160
|
+
},
|
|
161
|
+
filtered: filtered
|
|
162
|
+
)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def filter_entries(entries, exclude_patterns)
|
|
166
|
+
return entries if exclude_patterns.nil? || exclude_patterns.empty?
|
|
167
|
+
|
|
168
|
+
patterns = Atoms::PatternFilter.glob_to_regex(exclude_patterns)
|
|
169
|
+
entries.reject do |entry|
|
|
170
|
+
Atoms::PatternFilter.should_exclude?(entry[:path], patterns) ||
|
|
171
|
+
(entry[:rename_from] && Atoms::PatternFilter.should_exclude?(entry[:rename_from], patterns))
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|