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,253 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "timeout"
5
+
6
+ module Ace
7
+ module Git
8
+ module Atoms
9
+ # Pure functions for executing git commands safely
10
+ # Migrated from ace-git-diff
11
+ #
12
+ # Lock Retry Behavior:
13
+ # - Automatically retries git commands that encounter .git/index.lock errors
14
+ # - Uses progressive delays: 1s, 2s, 3s, 4s (total 10s across 4 retries)
15
+ # - On each retry, attempts to clean orphaned locks (dead PID) or stale locks (>10s)
16
+ # - Configurable via lock_retry section in .ace/git/config.yml
17
+ # - Only git commands are retried; non-git commands fail immediately
18
+ #
19
+ # This retry logic prevents "Unable to create .git/index.lock" errors
20
+ # that commonly occur in multi-worktree environments or when operations
21
+ # are interrupted (Ctrl+C, crashes, timeouts).
22
+ module CommandExecutor
23
+ class << self
24
+ # Execute a command safely using array arguments to prevent command injection
25
+ # @param command_parts [Array<String>] Command parts to execute
26
+ # @param timeout [Integer] Timeout in seconds (default from config)
27
+ # @param env [Hash] Optional environment variables to set for the command
28
+ # @return [Hash] Result with output, error, and success status
29
+ def execute(*command_parts, timeout: Ace::Git.git_timeout, env: nil)
30
+ # Check if lock retry is enabled (default: true)
31
+ lock_retry_config = Ace::Git.config["lock_retry"]
32
+ lock_retry_enabled = lock_retry_config.nil? || lock_retry_config["enabled"] != false
33
+
34
+ if lock_retry_enabled && !command_parts.empty? && command_parts.first == "git"
35
+ execute_with_lock_retry(command_parts, timeout: timeout, env: env, config: lock_retry_config)
36
+ else
37
+ execute_once(command_parts, timeout: timeout, env: env)
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ # Execute command with lock error retry logic
44
+ # @param command_parts [Array<String>] Command parts to execute
45
+ # @param timeout [Integer] Timeout in seconds
46
+ # @param env [Hash] Optional environment variables
47
+ # @param config [Hash] Lock retry configuration
48
+ # @return [Hash] Result with output, error, and success status
49
+ def execute_with_lock_retry(command_parts, timeout:, env:, config:)
50
+ config ||= {}
51
+ # Use fetch to respect zero values (e.g., max_retries: 0 to disable retries)
52
+ max_retries = config.fetch("max_retries", 4)
53
+ stale_cleanup = config.fetch("stale_cleanup", true)
54
+ stale_threshold = config.fetch("stale_threshold_seconds", 10)
55
+
56
+ result = nil
57
+ last_lock_info = nil
58
+ repo_root = nil
59
+
60
+ (0..max_retries).each do |attempt|
61
+ result = execute_once(command_parts, timeout: timeout, env: env)
62
+
63
+ # Success or non-lock error - return immediately
64
+ break if result[:success] || !LockErrorDetector.lock_error_result?(result)
65
+
66
+ # Attempt lock cleanup on every retry (checks orphaned PID first, then age)
67
+ if stale_cleanup
68
+ repo_root ||= CommandExecutor.repo_root
69
+ if repo_root
70
+ clean_result = StaleLockCleaner.clean(repo_root, stale_threshold)
71
+ last_lock_info = clean_result
72
+ if clean_result[:cleaned] && (ENV["ACE_DEBUG"] || ENV["DEBUG"])
73
+ warn "[ace-git] #{clean_result[:message]}"
74
+ end
75
+ end
76
+ end
77
+
78
+ # Sleep before retry (except on last attempt)
79
+ break if attempt == max_retries
80
+
81
+ # Progressive delays: 1s, 2s, 3s, 4s (total 10s across 4 retries)
82
+ sleep_seconds = attempt + 1
83
+ pid_note = last_lock_info&.dig(:pid) ? " (PID #{last_lock_info[:pid]})" : ""
84
+ warn "[ace-git] Lock detected#{pid_note}, waiting #{sleep_seconds}s (attempt #{attempt + 1}/#{max_retries + 1})..."
85
+ Kernel.sleep(sleep_seconds)
86
+ end
87
+
88
+ # Add retry context to error message if all retries failed
89
+ if !result[:success] && LockErrorDetector.lock_error_result?(result)
90
+ error = "Git index locked after #{max_retries + 1} attempts (#{max_retries} retries). #{result[:error]}"
91
+ if last_lock_info && last_lock_info[:status] == :active && last_lock_info[:pid]
92
+ error += " Active lock held by PID #{last_lock_info[:pid]}."
93
+ end
94
+ result[:error] = error
95
+ end
96
+
97
+ result
98
+ end
99
+
100
+ # Execute command once without retry logic
101
+ # @param command_parts [Array<String>] Command parts to execute
102
+ # @param timeout [Integer] Timeout in seconds
103
+ # @param env [Hash] Optional environment variables
104
+ # @return [Hash] Result with output, error, and success status
105
+ def execute_once(command_parts, timeout:, env:)
106
+ # Using Timeout to prevent hanging on network issues or stuck git operations
107
+ Timeout.timeout(timeout) do
108
+ # Using Open3.capture3 to avoid shell injection
109
+ # Arguments are passed directly as array elements, not through shell
110
+ # If env is provided, prepend it to the command (Open3 convention)
111
+ args = env ? [env, *command_parts] : command_parts
112
+ stdout, stderr, status = Open3.capture3(*args)
113
+
114
+ {
115
+ success: status.success?,
116
+ output: stdout,
117
+ error: stderr,
118
+ exit_code: status.exitstatus
119
+ }
120
+ end
121
+ rescue Timeout::Error
122
+ {
123
+ success: false,
124
+ output: "",
125
+ error: "Command timed out after #{timeout} seconds: #{command_parts.join(" ")}",
126
+ exit_code: -1
127
+ }
128
+ rescue => e
129
+ # Log backtrace for debugging when DEBUG environment variable is set
130
+ # This helps diagnose implementation bugs vs command failures
131
+ warn e.backtrace.join("\n") if ENV["DEBUG"]
132
+ {
133
+ success: false,
134
+ output: "",
135
+ error: e.message,
136
+ exit_code: -1
137
+ }
138
+ end
139
+
140
+ public
141
+
142
+ # Execute git diff command
143
+ # @param args [Array<String>] Arguments to pass to git diff
144
+ # @param raise_on_error [Boolean] If true, raises GitError on failure
145
+ # @return [String] Diff output (empty string if no changes, raises on error if raise_on_error)
146
+ def git_diff(*args, raise_on_error: false)
147
+ result = execute("git", "diff", *args)
148
+ if result[:success]
149
+ result[:output]
150
+ elsif raise_on_error
151
+ raise Ace::Git::GitError, "git diff failed: #{result[:error]}"
152
+ else
153
+ ""
154
+ end
155
+ end
156
+
157
+ # Get staged changes
158
+ # @return [String] Diff of staged changes
159
+ def staged_diff
160
+ result = execute("git", "diff", "--cached")
161
+ result[:success] ? result[:output] : ""
162
+ end
163
+
164
+ # Get working directory changes
165
+ # @return [String] Diff of working directory changes
166
+ def working_diff
167
+ result = execute("git", "diff")
168
+ result[:success] ? result[:output] : ""
169
+ end
170
+
171
+ # Check if we're in a git repository
172
+ # @return [Boolean] True if in a git repository
173
+ def in_git_repo?
174
+ result = execute("git", "rev-parse", "--git-dir")
175
+ result[:success]
176
+ end
177
+
178
+ # Get current branch name or commit SHA if detached
179
+ # @return [String, nil] Current branch name, commit SHA (if detached), or nil on error
180
+ def current_branch
181
+ result = execute("git", "rev-parse", "--abbrev-ref", "HEAD")
182
+ return nil unless result[:success]
183
+
184
+ branch = result[:output].strip
185
+ return branch unless branch == "HEAD"
186
+
187
+ # Detached HEAD - return commit SHA instead
188
+ sha_result = execute("git", "rev-parse", "HEAD")
189
+ sha_result[:success] ? sha_result[:output].strip : nil
190
+ end
191
+
192
+ # Get repository root path
193
+ # @return [String, nil] Repository root path or nil on error
194
+ def repo_root
195
+ result = execute_once(["git", "rev-parse", "--show-toplevel"], timeout: Ace::Git.git_timeout, env: nil)
196
+ result[:success] ? result[:output].strip : nil
197
+ end
198
+
199
+ # Get remote tracking branch
200
+ # @return [String, nil] Remote tracking branch or nil
201
+ def tracking_branch
202
+ result = execute("git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}")
203
+ result[:success] ? result[:output].strip : nil
204
+ end
205
+
206
+ # Get list of changed files
207
+ # @param range [String] Git range to check
208
+ # @return [Array<String>] List of changed file paths
209
+ def changed_files(range = nil)
210
+ range = "origin/main...HEAD" if range.nil? && ref_exists?("origin/main")
211
+ args = ["git", "diff", "--name-only"]
212
+ args << range if range && !range.empty?
213
+
214
+ result = execute(*args)
215
+ return [] unless result[:success]
216
+
217
+ result[:output].split("\n").map(&:strip).reject(&:empty?)
218
+ end
219
+
220
+ # Check if there are unstaged changes
221
+ # @return [Boolean] True if there are unstaged changes
222
+ def has_unstaged_changes?
223
+ !working_diff.strip.empty?
224
+ end
225
+
226
+ # Check if there are staged changes
227
+ # @return [Boolean] True if there are staged changes
228
+ def has_staged_changes?
229
+ !staged_diff.strip.empty?
230
+ end
231
+
232
+ # Check if there are untracked changes
233
+ # @return [Boolean] True if there are untracked files
234
+ def has_untracked_changes?
235
+ result = execute("git", "ls-files", "--others", "--exclude-standard")
236
+ result[:success] && !result[:output].strip.empty?
237
+ end
238
+
239
+ # Check whether a git reference exists in the repository.
240
+ #
241
+ # @param ref [String] Git ref to validate
242
+ # @return [Boolean] True if ref resolves, false otherwise
243
+ def ref_exists?(ref)
244
+ return false if ref.nil? || ref.strip.empty?
245
+
246
+ result = execute("git", "rev-parse", "--verify", "#{ref}^{}")
247
+ result[:success]
248
+ end
249
+ end
250
+ end
251
+ end
252
+ end
253
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module Ace
6
+ module Git
7
+ module Atoms
8
+ # Pure functions for resolving dates to git commits
9
+ # Migrated from ace-git-diff
10
+ module DateResolver
11
+ class << self
12
+ # Resolve a since parameter to a git commit reference
13
+ # @param since [String, Date] Date string, relative time, or commit SHA
14
+ # @param executor [Module] Command executor module (default: CommandExecutor)
15
+ # @return [String] Git commit reference
16
+ def resolve_since_to_commit(since, executor: CommandExecutor)
17
+ return "HEAD" if since.nil? || since.empty?
18
+
19
+ # If it looks like a commit SHA, use as-is
20
+ return since if commit_sha?(since)
21
+
22
+ # If it looks like a git ref (branch, tag), use as-is
23
+ return since if git_ref?(since)
24
+
25
+ # It's a date or relative time - find the first commit since that date
26
+ first_commit = find_first_commit_since(since, executor)
27
+ return since unless first_commit # Fallback to date string
28
+
29
+ # Get parent of first commit to include all changes since date
30
+ parent = get_parent_commit(first_commit, executor)
31
+ parent || first_commit
32
+ end
33
+
34
+ # Check if a string looks like a commit SHA
35
+ # @param ref [String] Reference to check
36
+ # @return [Boolean] True if looks like a commit SHA
37
+ def commit_sha?(ref)
38
+ !!(ref =~ /^[0-9a-f]{7,40}$/i)
39
+ end
40
+
41
+ # Check if a string looks like a git reference (branch, tag, etc)
42
+ # @param ref [String] Reference to check
43
+ # @return [Boolean] True if looks like a git ref
44
+ def git_ref?(ref)
45
+ # Check for common ref patterns:
46
+ # - refs/heads/*, refs/remotes/*, refs/tags/*
47
+ # - origin/main, upstream/develop, etc
48
+ # - HEAD, HEAD~1, HEAD^, etc
49
+ !!(ref =~ %r{^(refs/|origin/|upstream/|HEAD)}) ||
50
+ !!(ref =~ /^[A-Za-z][A-Za-z0-9_\-\/]*$/) # Branch or tag name
51
+ end
52
+
53
+ # Parse relative time strings (e.g., "7d", "1 week ago", "2 months")
54
+ # @param time_str [String] Time string to parse
55
+ # @return [String, nil] ISO date string or nil if can't parse
56
+ def parse_relative_time(time_str)
57
+ case time_str
58
+ when /^(\d+)d$/ # e.g., "7d"
59
+ days = Regexp.last_match(1).to_i
60
+ (Date.today - days).strftime("%Y-%m-%d")
61
+ when /^(\d+)\s*days?\s*ago$/i # e.g., "7 days ago"
62
+ days = Regexp.last_match(1).to_i
63
+ (Date.today - days).strftime("%Y-%m-%d")
64
+ when /^(\d+)\s*weeks?\s*ago$/i # e.g., "1 week ago"
65
+ weeks = Regexp.last_match(1).to_i
66
+ (Date.today - (weeks * 7)).strftime("%Y-%m-%d")
67
+ when /^(\d+)\s*months?\s*ago$/i # e.g., "2 months ago"
68
+ months = Regexp.last_match(1).to_i
69
+ (Date.today << months).strftime("%Y-%m-%d")
70
+ else
71
+ # Try to parse as date string
72
+ begin
73
+ Date.parse(time_str).strftime("%Y-%m-%d")
74
+ rescue ArgumentError
75
+ nil
76
+ end
77
+ end
78
+ end
79
+
80
+ # Format since parameter for git commands
81
+ # @param since [String, Date, Time] Since parameter
82
+ # @return [String] Formatted since string
83
+ def format_since(since)
84
+ case since
85
+ when Date
86
+ since.strftime("%Y-%m-%d")
87
+ when Time
88
+ since.strftime("%Y-%m-%d")
89
+ when String
90
+ # Try to parse relative time
91
+ parsed = parse_relative_time(since)
92
+ parsed || since
93
+ else
94
+ (Date.today - 7).strftime("%Y-%m-%d")
95
+ end
96
+ end
97
+
98
+ private
99
+
100
+ # Find the first commit since a given date
101
+ # @param since [String, nil] Date string or relative time
102
+ # @param executor [Module] Command executor
103
+ # @return [String, nil] First commit SHA or nil
104
+ def find_first_commit_since(since, executor)
105
+ # Handle nil/empty since parameter
106
+ return nil if since.nil? || since.to_s.strip.empty?
107
+
108
+ # Parse relative time if needed
109
+ date_str = parse_relative_time(since) || since
110
+
111
+ result = executor.execute("git", "log", "--since=#{date_str}", "--format=%H", "--reverse", "--all")
112
+ return nil unless result[:success]
113
+
114
+ commits = result[:output].strip.split("\n")
115
+ commits.first
116
+ end
117
+
118
+ # Get the parent commit of a given commit
119
+ def get_parent_commit(commit, executor)
120
+ result = executor.execute("git", "rev-parse", "#{commit}~1")
121
+ result[:success] ? result[:output].strip : nil
122
+ rescue
123
+ nil
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Git
5
+ module Atoms
6
+ # Parse `git diff --numstat` output into structured entries.
7
+ module DiffNumstatParser
8
+ class << self
9
+ def parse(numstat_output)
10
+ return [] if numstat_output.nil? || numstat_output.strip.empty?
11
+
12
+ numstat_output.split("\n").map { |line| parse_line(line) }.compact
13
+ end
14
+
15
+ private
16
+
17
+ def parse_line(line)
18
+ return nil if line.nil? || line.strip.empty?
19
+
20
+ additions_raw, deletions_raw, raw_path = line.split("\t", 3)
21
+ return nil if raw_path.nil?
22
+
23
+ rename_info = parse_rename(raw_path)
24
+ binary = additions_raw == "-" && deletions_raw == "-"
25
+
26
+ {
27
+ path: rename_info[:to],
28
+ display_path: rename_info[:display],
29
+ additions: binary ? nil : additions_raw.to_i,
30
+ deletions: binary ? nil : deletions_raw.to_i,
31
+ binary: binary,
32
+ rename_from: rename_info[:from],
33
+ rename_to: rename_info[:to]
34
+ }
35
+ end
36
+
37
+ def parse_rename(path)
38
+ if path.include?(" => ")
39
+ from, to = expand_brace_rename(path)
40
+ return {
41
+ from: from,
42
+ to: to,
43
+ display: "#{from} -> #{to}"
44
+ }
45
+ end
46
+
47
+ {
48
+ from: nil,
49
+ to: path,
50
+ display: path
51
+ }
52
+ end
53
+
54
+ # Handles brace syntax: foo/{old.rb => new.rb}
55
+ def expand_brace_rename(path)
56
+ # Supports empty side of brace rename, e.g.:
57
+ # tasks/{ => _archive}/file.md
58
+ brace_match = path.match(/\A(.*)\{(.*) => (.*)\}(.*)\z/)
59
+ if brace_match
60
+ prefix = brace_match[1]
61
+ from_inner = brace_match[2]
62
+ to_inner = brace_match[3]
63
+ suffix = brace_match[4]
64
+ return [
65
+ build_renamed_path(prefix, from_inner, suffix),
66
+ build_renamed_path(prefix, to_inner, suffix)
67
+ ]
68
+ end
69
+
70
+ # Fallback for unbraced exact renames: old.rb => new.rb (no common prefix/suffix)
71
+ split = path.split(" => ", 2)
72
+ [split[0], split[1]]
73
+ end
74
+
75
+ def build_renamed_path(prefix, inner, suffix)
76
+ "#{prefix}#{inner}#{suffix}".squeeze("/")
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Git
5
+ module Atoms
6
+ # Pure functions for parsing diff output
7
+ # Migrated from ace-git-diff
8
+ module DiffParser
9
+ class << self
10
+ # Estimate the size of a diff in lines
11
+ # @param diff [String] The diff content
12
+ # @return [Integer] Number of lines
13
+ def count_lines(diff)
14
+ return 0 if diff.nil? || diff.empty?
15
+
16
+ diff.count("\n") + 1
17
+ end
18
+
19
+ # Count significant changes (additions and deletions)
20
+ # @param diff [String] The diff content
21
+ # @return [Hash] Statistics about the diff
22
+ def count_changes(diff)
23
+ return {additions: 0, deletions: 0, files: 0, total_changes: 0} if diff.nil? || diff.empty?
24
+
25
+ additions = 0
26
+ deletions = 0
27
+ files = 0
28
+
29
+ diff.split("\n").each do |line|
30
+ if file_header_line?(line)
31
+ files += 1
32
+ elsif line.start_with?("+") && !line.start_with?("+++")
33
+ additions += 1
34
+ elsif line.start_with?("-") && !line.start_with?("---")
35
+ deletions += 1
36
+ end
37
+ end
38
+
39
+ {
40
+ additions: additions,
41
+ deletions: deletions,
42
+ files: files,
43
+ total_changes: additions + deletions
44
+ }
45
+ end
46
+
47
+ # Check if diff exceeds a size limit
48
+ # @param diff [String] The diff content
49
+ # @param max_lines [Integer] Maximum allowed lines
50
+ # @return [Boolean] True if diff exceeds limit
51
+ def exceeds_limit?(diff, max_lines)
52
+ count_lines(diff) > max_lines
53
+ end
54
+
55
+ # Extract list of files from diff
56
+ # @param diff [String] The diff content
57
+ # @return [Array<String>] List of file paths
58
+ def extract_files(diff)
59
+ return [] if diff.nil? || diff.empty?
60
+
61
+ files = []
62
+ diff.split("\n").each do |line|
63
+ if line.start_with?("diff --git")
64
+ # Extract file path from "diff --git a/path b/path"
65
+ if line =~ /^diff --git a\/(.+) b\/(.+)$/
66
+ files << Regexp.last_match(2) # Use 'b/' path (new file)
67
+ end
68
+ end
69
+ end
70
+
71
+ files.uniq
72
+ end
73
+
74
+ # Parse diff into structured data
75
+ # @param diff [String] The diff content
76
+ # @return [Hash] Parsed diff data
77
+ def parse(diff)
78
+ {
79
+ content: diff,
80
+ stats: count_changes(diff),
81
+ files: extract_files(diff),
82
+ line_count: count_lines(diff),
83
+ empty: diff.nil? || diff.strip.empty?
84
+ }
85
+ end
86
+
87
+ # Check if diff contains any actual changes
88
+ # @param diff [String] The diff content
89
+ # @return [Boolean] True if diff has changes
90
+ def has_changes?(diff)
91
+ return false if diff.nil? || diff.strip.empty?
92
+
93
+ # Check for addition or deletion lines
94
+ diff.split("\n").any? do |line|
95
+ (line.start_with?("+") && !line.start_with?("+++")) ||
96
+ (line.start_with?("-") && !line.start_with?("---"))
97
+ end
98
+ end
99
+
100
+ private
101
+
102
+ # Check if a line is a file header (starts a new file in diff)
103
+ def file_header_line?(line)
104
+ line.start_with?("diff --git")
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end