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.
Files changed (72) hide show
  1. checksums.yaml +7 -0
  2. data/.ace-defaults/git/config.yml +83 -0
  3. data/.ace-defaults/nav/protocols/guide-sources/ace-git.yml +10 -0
  4. data/.ace-defaults/nav/protocols/tmpl-sources/ace-git.yml +19 -0
  5. data/.ace-defaults/nav/protocols/wfi-sources/ace-git.yml +19 -0
  6. data/CHANGELOG.md +762 -0
  7. data/LICENSE +21 -0
  8. data/README.md +48 -0
  9. data/Rakefile +14 -0
  10. data/docs/demo/ace-git-getting-started.gif +0 -0
  11. data/docs/demo/ace-git-getting-started.tape.yml +18 -0
  12. data/docs/demo/fixtures/README.md +3 -0
  13. data/docs/demo/fixtures/sample.txt +1 -0
  14. data/docs/getting-started.md +87 -0
  15. data/docs/handbook.md +50 -0
  16. data/docs/usage.md +259 -0
  17. data/exe/ace-git +37 -0
  18. data/handbook/guides/version-control/ruby.md +41 -0
  19. data/handbook/guides/version-control/rust.md +49 -0
  20. data/handbook/guides/version-control/typescript.md +47 -0
  21. data/handbook/guides/version-control-system-git.g.md +829 -0
  22. data/handbook/skills/as-git-rebase/SKILL.md +43 -0
  23. data/handbook/skills/as-git-reorganize-commits/SKILL.md +41 -0
  24. data/handbook/skills/as-github-pr-create/SKILL.md +60 -0
  25. data/handbook/skills/as-github-pr-update/SKILL.md +41 -0
  26. data/handbook/skills/as-github-release-publish/SKILL.md +58 -0
  27. data/handbook/templates/commit/squash.template.md +59 -0
  28. data/handbook/templates/pr/bugfix.template.md +103 -0
  29. data/handbook/templates/pr/default.template.md +40 -0
  30. data/handbook/templates/pr/feature.template.md +41 -0
  31. data/handbook/workflow-instructions/git/rebase.wf.md +402 -0
  32. data/handbook/workflow-instructions/git/reorganize-commits.wf.md +158 -0
  33. data/handbook/workflow-instructions/github/pr/create.wf.md +282 -0
  34. data/handbook/workflow-instructions/github/pr/update.wf.md +199 -0
  35. data/handbook/workflow-instructions/github/release-publish.wf.md +162 -0
  36. data/lib/ace/git/atoms/command_executor.rb +253 -0
  37. data/lib/ace/git/atoms/date_resolver.rb +129 -0
  38. data/lib/ace/git/atoms/diff_numstat_parser.rb +82 -0
  39. data/lib/ace/git/atoms/diff_parser.rb +110 -0
  40. data/lib/ace/git/atoms/file_grouper.rb +152 -0
  41. data/lib/ace/git/atoms/git_scope_filter.rb +86 -0
  42. data/lib/ace/git/atoms/git_status_fetcher.rb +29 -0
  43. data/lib/ace/git/atoms/grouped_stats_formatter.rb +233 -0
  44. data/lib/ace/git/atoms/lock_error_detector.rb +79 -0
  45. data/lib/ace/git/atoms/pattern_filter.rb +156 -0
  46. data/lib/ace/git/atoms/pr_identifier_parser.rb +88 -0
  47. data/lib/ace/git/atoms/repository_checker.rb +97 -0
  48. data/lib/ace/git/atoms/repository_state_detector.rb +92 -0
  49. data/lib/ace/git/atoms/stale_lock_cleaner.rb +247 -0
  50. data/lib/ace/git/atoms/status_formatter.rb +180 -0
  51. data/lib/ace/git/atoms/task_pattern_extractor.rb +57 -0
  52. data/lib/ace/git/atoms/time_formatter.rb +84 -0
  53. data/lib/ace/git/cli/commands/branch.rb +62 -0
  54. data/lib/ace/git/cli/commands/diff.rb +252 -0
  55. data/lib/ace/git/cli/commands/pr.rb +119 -0
  56. data/lib/ace/git/cli/commands/status.rb +84 -0
  57. data/lib/ace/git/cli.rb +87 -0
  58. data/lib/ace/git/models/diff_config.rb +185 -0
  59. data/lib/ace/git/models/diff_result.rb +94 -0
  60. data/lib/ace/git/models/repo_status.rb +202 -0
  61. data/lib/ace/git/molecules/branch_reader.rb +92 -0
  62. data/lib/ace/git/molecules/config_loader.rb +108 -0
  63. data/lib/ace/git/molecules/diff_filter.rb +102 -0
  64. data/lib/ace/git/molecules/diff_generator.rb +160 -0
  65. data/lib/ace/git/molecules/git_status_fetcher.rb +32 -0
  66. data/lib/ace/git/molecules/pr_metadata_fetcher.rb +286 -0
  67. data/lib/ace/git/molecules/recent_commits_fetcher.rb +53 -0
  68. data/lib/ace/git/organisms/diff_orchestrator.rb +178 -0
  69. data/lib/ace/git/organisms/repo_status_loader.rb +264 -0
  70. data/lib/ace/git/version.rb +7 -0
  71. data/lib/ace/git.rb +230 -0
  72. 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