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,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
|