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,202 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Git
|
|
5
|
+
module Models
|
|
6
|
+
# Data structure representing repository status
|
|
7
|
+
# Includes branch info, task pattern, PR metadata, and repository state
|
|
8
|
+
class RepoStatus
|
|
9
|
+
attr_reader :branch, :tracking, :ahead, :behind, :task_pattern,
|
|
10
|
+
:pr_metadata, :pr_activity, :git_status_sb, :recent_commits,
|
|
11
|
+
:repository_type, :repository_state
|
|
12
|
+
|
|
13
|
+
# @param branch [String] Current branch name
|
|
14
|
+
# @param tracking [String, nil] Remote tracking branch
|
|
15
|
+
# @param ahead [Integer] Commits ahead of remote
|
|
16
|
+
# @param behind [Integer] Commits behind remote
|
|
17
|
+
# @param task_pattern [String, nil] Detected task pattern from branch
|
|
18
|
+
# @param pr_metadata [Hash, nil] PR metadata if available
|
|
19
|
+
# @param pr_activity [Hash, nil] PR activity (merged and open PRs)
|
|
20
|
+
# @param git_status_sb [String, nil] Output of git status -sb
|
|
21
|
+
# @param recent_commits [Array, nil] Recent commits array
|
|
22
|
+
# @param repository_type [Symbol] :normal, :detached, :bare, :worktree, :not_git
|
|
23
|
+
# @param repository_state [Symbol] :clean, :dirty, :rebasing, :merging
|
|
24
|
+
def initialize(
|
|
25
|
+
branch:,
|
|
26
|
+
tracking: nil,
|
|
27
|
+
ahead: 0,
|
|
28
|
+
behind: 0,
|
|
29
|
+
task_pattern: nil,
|
|
30
|
+
pr_metadata: nil,
|
|
31
|
+
pr_activity: nil,
|
|
32
|
+
git_status_sb: nil,
|
|
33
|
+
recent_commits: nil,
|
|
34
|
+
repository_type: :normal,
|
|
35
|
+
repository_state: :clean
|
|
36
|
+
)
|
|
37
|
+
@branch = branch
|
|
38
|
+
@tracking = tracking
|
|
39
|
+
@ahead = ahead
|
|
40
|
+
@behind = behind
|
|
41
|
+
@task_pattern = task_pattern
|
|
42
|
+
@pr_metadata = pr_metadata
|
|
43
|
+
@pr_activity = pr_activity
|
|
44
|
+
@git_status_sb = git_status_sb
|
|
45
|
+
@recent_commits = recent_commits
|
|
46
|
+
@repository_type = repository_type
|
|
47
|
+
@repository_state = repository_state
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Check if branch is detached
|
|
51
|
+
# @return [Boolean] True if detached HEAD
|
|
52
|
+
def detached?
|
|
53
|
+
branch == "HEAD" || repository_type == :detached
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Check if tracking remote
|
|
57
|
+
# @return [Boolean] True if has tracking branch
|
|
58
|
+
def tracking?
|
|
59
|
+
!tracking.nil? && !tracking.empty?
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Check if up to date with remote
|
|
63
|
+
# @return [Boolean] True if no ahead/behind
|
|
64
|
+
def up_to_date?
|
|
65
|
+
ahead == 0 && behind == 0
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Check if has associated PR
|
|
69
|
+
# @return [Boolean] True if PR metadata present
|
|
70
|
+
def has_pr?
|
|
71
|
+
!pr_metadata.nil? && !pr_metadata.empty?
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Check if has detected task pattern
|
|
75
|
+
# @return [Boolean] True if task pattern found
|
|
76
|
+
def has_task_pattern?
|
|
77
|
+
!task_pattern.nil? && !task_pattern.empty?
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Check if has PR activity data
|
|
81
|
+
# @return [Boolean] True if any merged or open PRs present
|
|
82
|
+
def has_pr_activity?
|
|
83
|
+
return false if pr_activity.nil?
|
|
84
|
+
|
|
85
|
+
merged = pr_activity[:merged] || pr_activity["merged"] || []
|
|
86
|
+
open = pr_activity[:open] || pr_activity["open"] || []
|
|
87
|
+
!merged.empty? || !open.empty?
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Check if has recent commits data
|
|
91
|
+
# @return [Boolean] True if recent commits present
|
|
92
|
+
def has_recent_commits?
|
|
93
|
+
!recent_commits.nil? && !recent_commits.empty?
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Check if has git status output
|
|
97
|
+
# @return [Boolean] True if git status output present
|
|
98
|
+
def has_git_status?
|
|
99
|
+
!git_status_sb.nil? && !git_status_sb.empty?
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Check if repository is clean
|
|
103
|
+
# @return [Boolean] True if no uncommitted changes
|
|
104
|
+
def clean?
|
|
105
|
+
repository_state == :clean
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Count dirty files from git status output
|
|
109
|
+
# @return [Integer] Number of dirty files (non-branch lines in git status -sb)
|
|
110
|
+
def dirty_file_count
|
|
111
|
+
return 0 unless has_git_status?
|
|
112
|
+
|
|
113
|
+
git_status_sb.lines.count { |l| !l.start_with?("##") }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Get tracking status description
|
|
117
|
+
# @return [String] Human-readable status
|
|
118
|
+
def tracking_status
|
|
119
|
+
return "no tracking branch" unless tracking?
|
|
120
|
+
|
|
121
|
+
if up_to_date?
|
|
122
|
+
"up to date"
|
|
123
|
+
elsif ahead > 0 && behind > 0
|
|
124
|
+
"#{ahead} ahead, #{behind} behind"
|
|
125
|
+
elsif ahead > 0
|
|
126
|
+
"#{ahead} ahead"
|
|
127
|
+
else
|
|
128
|
+
"#{behind} behind"
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Convert to hash
|
|
133
|
+
# @return [Hash] Hash representation
|
|
134
|
+
def to_h
|
|
135
|
+
{
|
|
136
|
+
branch: branch,
|
|
137
|
+
tracking: tracking,
|
|
138
|
+
ahead: ahead,
|
|
139
|
+
behind: behind,
|
|
140
|
+
up_to_date: up_to_date?,
|
|
141
|
+
task_pattern: task_pattern,
|
|
142
|
+
pr_metadata: pr_metadata,
|
|
143
|
+
pr_activity: pr_activity,
|
|
144
|
+
git_status_sb: git_status_sb,
|
|
145
|
+
recent_commits: recent_commits,
|
|
146
|
+
repository_type: repository_type,
|
|
147
|
+
repository_state: repository_state,
|
|
148
|
+
detached: detached?,
|
|
149
|
+
has_pr: has_pr?,
|
|
150
|
+
has_pr_activity: has_pr_activity?,
|
|
151
|
+
has_recent_commits: has_recent_commits?,
|
|
152
|
+
has_git_status: has_git_status?,
|
|
153
|
+
has_task_pattern: has_task_pattern?,
|
|
154
|
+
clean: clean?,
|
|
155
|
+
dirty_files: dirty_file_count
|
|
156
|
+
}
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Convert to JSON
|
|
160
|
+
# @return [String] JSON representation
|
|
161
|
+
def to_json(*args)
|
|
162
|
+
to_h.to_json(*args)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Generate markdown output
|
|
166
|
+
# @return [String] Markdown-formatted status
|
|
167
|
+
# @note Delegates to Atoms::StatusFormatter for ATOM pattern compliance
|
|
168
|
+
def to_markdown
|
|
169
|
+
Atoms::StatusFormatter.to_markdown(self)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Create from loaded data
|
|
173
|
+
# @param branch_info [Hash] Branch information
|
|
174
|
+
# @param task_pattern [String, nil] Detected task pattern
|
|
175
|
+
# @param pr_metadata [Hash, nil] PR metadata
|
|
176
|
+
# @param pr_activity [Hash, nil] PR activity (merged and open PRs)
|
|
177
|
+
# @param git_status_sb [String, nil] Output of git status -sb
|
|
178
|
+
# @param recent_commits [Array, nil] Recent commits array
|
|
179
|
+
# @param repo_type [Symbol] Repository type
|
|
180
|
+
# @param repo_state [Symbol] Repository state
|
|
181
|
+
# @return [RepoStatus] New instance
|
|
182
|
+
def self.from_data(branch_info:, task_pattern: nil, pr_metadata: nil,
|
|
183
|
+
pr_activity: nil, git_status_sb: nil, recent_commits: nil,
|
|
184
|
+
repo_type: :normal, repo_state: :clean)
|
|
185
|
+
new(
|
|
186
|
+
branch: branch_info[:name] || branch_info["name"],
|
|
187
|
+
tracking: branch_info[:tracking] || branch_info["tracking"],
|
|
188
|
+
ahead: branch_info[:ahead] || branch_info["ahead"] || 0,
|
|
189
|
+
behind: branch_info[:behind] || branch_info["behind"] || 0,
|
|
190
|
+
task_pattern: task_pattern,
|
|
191
|
+
pr_metadata: pr_metadata,
|
|
192
|
+
pr_activity: pr_activity,
|
|
193
|
+
git_status_sb: git_status_sb,
|
|
194
|
+
recent_commits: recent_commits,
|
|
195
|
+
repository_type: repo_type,
|
|
196
|
+
repository_state: repo_state
|
|
197
|
+
)
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Git
|
|
5
|
+
module Molecules
|
|
6
|
+
# Reads git branch information
|
|
7
|
+
# Consolidated from ace-review GitBranchReader (adapted to use CommandExecutor)
|
|
8
|
+
class BranchReader
|
|
9
|
+
class << self
|
|
10
|
+
# Get current git branch name or commit SHA if detached
|
|
11
|
+
# @param executor [Module] Command executor
|
|
12
|
+
# @return [String|nil] branch name, commit SHA (if detached), or nil if not in git repo
|
|
13
|
+
def current_branch(executor: Atoms::CommandExecutor)
|
|
14
|
+
executor.current_branch
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Check if HEAD is detached
|
|
18
|
+
# @param executor [Module] Command executor
|
|
19
|
+
# @return [Boolean] true if HEAD is detached
|
|
20
|
+
def detached?(executor: Atoms::CommandExecutor)
|
|
21
|
+
result = executor.execute("git", "rev-parse", "--abbrev-ref", "HEAD")
|
|
22
|
+
return false unless result[:success]
|
|
23
|
+
|
|
24
|
+
result[:output].strip == "HEAD"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Get remote tracking branch
|
|
28
|
+
# @param executor [Module] Command executor
|
|
29
|
+
# @return [String|nil] tracking branch name or nil
|
|
30
|
+
def tracking_branch(executor: Atoms::CommandExecutor)
|
|
31
|
+
executor.tracking_branch
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Get remote tracking status (ahead/behind counts)
|
|
35
|
+
# @param executor [Module] Command executor
|
|
36
|
+
# @return [Hash] { ahead: Integer, behind: Integer }
|
|
37
|
+
def tracking_status(executor: Atoms::CommandExecutor)
|
|
38
|
+
result = executor.execute("git", "rev-list", "--left-right", "--count", "@{upstream}...HEAD")
|
|
39
|
+
|
|
40
|
+
unless result[:success]
|
|
41
|
+
return {ahead: 0, behind: 0, error: "No tracking branch or not in git repo"}
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
parts = result[:output].strip.split(/\s+/)
|
|
45
|
+
{
|
|
46
|
+
ahead: parts[1].to_i,
|
|
47
|
+
behind: parts[0].to_i
|
|
48
|
+
}
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Get full branch information
|
|
52
|
+
# @param executor [Module] Command executor
|
|
53
|
+
# @return [Hash] Branch information
|
|
54
|
+
def full_info(executor: Atoms::CommandExecutor)
|
|
55
|
+
branch = current_branch(executor: executor)
|
|
56
|
+
|
|
57
|
+
return {error: "Not in git repository or no branch"} if branch.nil?
|
|
58
|
+
|
|
59
|
+
tracking = tracking_branch(executor: executor)
|
|
60
|
+
status = tracking_status(executor: executor)
|
|
61
|
+
|
|
62
|
+
{
|
|
63
|
+
name: branch,
|
|
64
|
+
detached: detached?(executor: executor),
|
|
65
|
+
tracking: tracking,
|
|
66
|
+
ahead: status[:ahead],
|
|
67
|
+
behind: status[:behind],
|
|
68
|
+
up_to_date: status[:ahead] == 0 && status[:behind] == 0,
|
|
69
|
+
status_description: format_status(status[:ahead], status[:behind])
|
|
70
|
+
}
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Format tracking status as human-readable string
|
|
74
|
+
# @param ahead [Integer] Commits ahead of remote
|
|
75
|
+
# @param behind [Integer] Commits behind remote
|
|
76
|
+
# @return [String] Status description
|
|
77
|
+
def format_status(ahead, behind)
|
|
78
|
+
if ahead == 0 && behind == 0
|
|
79
|
+
"up to date"
|
|
80
|
+
elsif ahead > 0 && behind > 0
|
|
81
|
+
"#{ahead} ahead, #{behind} behind"
|
|
82
|
+
elsif ahead > 0
|
|
83
|
+
"#{ahead} ahead"
|
|
84
|
+
else
|
|
85
|
+
"#{behind} behind"
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ace/support/config"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Git
|
|
7
|
+
module Molecules
|
|
8
|
+
# Load and merge diff configuration from cascade
|
|
9
|
+
# Follows ADR-022: Configuration Default and Override Pattern
|
|
10
|
+
# Uses Config.merge() for consistent merge strategy support
|
|
11
|
+
# Migrated from ace-git-diff
|
|
12
|
+
class ConfigLoader
|
|
13
|
+
class << self
|
|
14
|
+
# Load configuration using ace-config cascade with deep merge
|
|
15
|
+
# Priority: instance_config merged over global config (which is already defaults + user)
|
|
16
|
+
# @param instance_config [Hash] Instance-level configuration (highest priority)
|
|
17
|
+
# @return [Models::DiffConfig] Merged configuration
|
|
18
|
+
def load(instance_config = {})
|
|
19
|
+
# Get global config from ace-git (already merged defaults + user per ADR-022)
|
|
20
|
+
global_config = Ace::Git.config || {}
|
|
21
|
+
|
|
22
|
+
# Extract diff config from the global config (handles diff: namespace)
|
|
23
|
+
global_diff_config = extract_diff_config(global_config)
|
|
24
|
+
|
|
25
|
+
# Use Config.merge() for consistent merge strategy support
|
|
26
|
+
# This enables future per-key merge strategies via _merge directive
|
|
27
|
+
config_hash = Ace::Support::Config::Models::Config.new(global_diff_config, source: "git_global")
|
|
28
|
+
.merge(instance_config)
|
|
29
|
+
.to_h
|
|
30
|
+
|
|
31
|
+
Models::DiffConfig.from_hash(config_hash)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Load configuration for a specific gem integration
|
|
35
|
+
# Priority: instance_config > gem_config > global_config (all deep merged)
|
|
36
|
+
# @param gem_config [Hash] Gem-specific configuration
|
|
37
|
+
# @param instance_config [Hash] Instance-level overrides
|
|
38
|
+
# @return [Models::DiffConfig] Merged configuration
|
|
39
|
+
def load_for_gem(gem_config, instance_config = {})
|
|
40
|
+
# Start with global config (already merged defaults + user per ADR-022)
|
|
41
|
+
global_config = Ace::Git.config || {}
|
|
42
|
+
|
|
43
|
+
# Extract diff config from global (handles diff: namespace)
|
|
44
|
+
global_diff_config = extract_diff_config(global_config)
|
|
45
|
+
gem_diff_config = extract_diff_config(gem_config)
|
|
46
|
+
|
|
47
|
+
# Use Config.merge() cascade: global -> gem -> instance
|
|
48
|
+
# This enables future per-key merge strategies via _merge directive
|
|
49
|
+
config_hash = Ace::Support::Config::Models::Config.new(global_diff_config, source: "git_global")
|
|
50
|
+
.merge(gem_diff_config)
|
|
51
|
+
.merge(instance_config)
|
|
52
|
+
.to_h
|
|
53
|
+
|
|
54
|
+
Models::DiffConfig.from_hash(config_hash)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Extract diff configuration from various config formats
|
|
58
|
+
# Supports both diff: key and legacy/direct formats
|
|
59
|
+
# When config contains both top-level diff keys AND a nested diff: section,
|
|
60
|
+
# merges them (nested overrides top-level) to preserve defaults
|
|
61
|
+
# @param config [Hash] Configuration hash
|
|
62
|
+
# @return [Hash] Extracted diff configuration
|
|
63
|
+
def extract_diff_config(config)
|
|
64
|
+
return {} if config.nil? || config.empty?
|
|
65
|
+
|
|
66
|
+
# First, collect any top-level diff keys (flattened defaults)
|
|
67
|
+
diff_keys = %w[exclude_patterns exclude_whitespace exclude_renames
|
|
68
|
+
exclude_moves max_lines ranges paths since format timeout grouped_stats]
|
|
69
|
+
diff_sym_keys = diff_keys.map(&:to_sym)
|
|
70
|
+
|
|
71
|
+
top_level_diff = {}
|
|
72
|
+
diff_keys.each { |k| top_level_diff[k] = config[k] if config.key?(k) }
|
|
73
|
+
diff_sym_keys.each { |k| top_level_diff[k.to_s] = config[k] if config.key?(k) }
|
|
74
|
+
|
|
75
|
+
# Check for explicit diff: key (nested under git: from config cascade)
|
|
76
|
+
if config.key?("diff") || config.key?(:diff)
|
|
77
|
+
diff_config = config["diff"] || config[:diff]
|
|
78
|
+
if diff_config.is_a?(Hash)
|
|
79
|
+
# Merge nested diff over top-level using Config.merge()
|
|
80
|
+
return Ace::Support::Config::Models::Config.new(top_level_diff, source: "git_diff_extract")
|
|
81
|
+
.merge(diff_config)
|
|
82
|
+
.to_h
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Return top-level diff keys if we found any
|
|
87
|
+
return top_level_diff unless top_level_diff.empty?
|
|
88
|
+
|
|
89
|
+
# Check for legacy diffs: array format
|
|
90
|
+
if config.key?("diffs") || config.key?(:diffs)
|
|
91
|
+
diffs = config["diffs"] || config[:diffs]
|
|
92
|
+
return {"ranges" => Array(diffs)} if diffs
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Check for legacy filters format (ace-docs)
|
|
96
|
+
if config.key?("filters") || config.key?(:filters)
|
|
97
|
+
filters = config["filters"] || config[:filters]
|
|
98
|
+
return {"paths" => Array(filters)} if filters
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# No diff config found
|
|
102
|
+
{}
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Git
|
|
5
|
+
module Molecules
|
|
6
|
+
# Apply filtering to diff output based on configuration
|
|
7
|
+
# Migrated from ace-git-diff
|
|
8
|
+
class DiffFilter
|
|
9
|
+
class << self
|
|
10
|
+
# Filter diff content based on configuration
|
|
11
|
+
# @param diff [String] Raw diff content
|
|
12
|
+
# @param config [Models::DiffConfig] Diff configuration
|
|
13
|
+
# @return [String] Filtered diff content
|
|
14
|
+
def filter(diff, config)
|
|
15
|
+
return "" if diff.nil? || diff.empty?
|
|
16
|
+
return diff if config.exclude_patterns.empty?
|
|
17
|
+
|
|
18
|
+
# Convert glob patterns to regex
|
|
19
|
+
regex_patterns = Atoms::PatternFilter.glob_to_regex(config.exclude_patterns)
|
|
20
|
+
|
|
21
|
+
# Apply pattern filtering
|
|
22
|
+
filtered = Atoms::PatternFilter.filter_diff_by_patterns(diff, regex_patterns)
|
|
23
|
+
|
|
24
|
+
# Check if exceeds max lines
|
|
25
|
+
if config.max_lines && Atoms::DiffParser.exceeds_limit?(filtered, config.max_lines)
|
|
26
|
+
truncate(filtered, config.max_lines)
|
|
27
|
+
else
|
|
28
|
+
filtered
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Apply only path-based filtering (no size limits)
|
|
33
|
+
# @param diff [String] Raw diff content
|
|
34
|
+
# @param exclude_patterns [Array<String>] Glob patterns to exclude
|
|
35
|
+
# @return [String] Filtered diff content
|
|
36
|
+
def filter_by_patterns(diff, exclude_patterns)
|
|
37
|
+
return diff if diff.nil? || diff.empty? || exclude_patterns.empty?
|
|
38
|
+
|
|
39
|
+
regex_patterns = Atoms::PatternFilter.glob_to_regex(exclude_patterns)
|
|
40
|
+
Atoms::PatternFilter.filter_diff_by_patterns(diff, regex_patterns)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Apply include patterns (only show matching files)
|
|
44
|
+
# @param diff [String] Raw diff content
|
|
45
|
+
# @param include_patterns [Array<String>] Glob patterns to include
|
|
46
|
+
# @return [String] Filtered diff content
|
|
47
|
+
def filter_by_includes(diff, include_patterns)
|
|
48
|
+
return diff if diff.nil? || diff.empty? || include_patterns.nil? || include_patterns.empty?
|
|
49
|
+
|
|
50
|
+
lines = diff.split("\n")
|
|
51
|
+
filtered_lines = []
|
|
52
|
+
include_current_file = false
|
|
53
|
+
|
|
54
|
+
lines.each do |line|
|
|
55
|
+
# Check if this is a file header
|
|
56
|
+
if Atoms::PatternFilter.file_header?(line)
|
|
57
|
+
file_path = Atoms::PatternFilter.extract_file_path(line)
|
|
58
|
+
# Include file if it matches any include pattern
|
|
59
|
+
include_current_file = Atoms::PatternFilter.matches_include?(file_path, include_patterns)
|
|
60
|
+
filtered_lines << line if include_current_file
|
|
61
|
+
elsif include_current_file
|
|
62
|
+
filtered_lines << line
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
filtered_lines.join("\n")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Truncate diff to maximum number of lines
|
|
70
|
+
# @param diff [String] Diff content
|
|
71
|
+
# @param max_lines [Integer] Maximum lines to keep
|
|
72
|
+
# @return [String] Truncated diff with note
|
|
73
|
+
def truncate(diff, max_lines)
|
|
74
|
+
return diff if diff.nil? || diff.empty?
|
|
75
|
+
|
|
76
|
+
lines = diff.split("\n")
|
|
77
|
+
return diff if lines.length <= max_lines
|
|
78
|
+
|
|
79
|
+
truncated = lines[0...max_lines].join("\n")
|
|
80
|
+
truncated + "\n\n... (diff truncated at #{max_lines} lines)"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Get filtering statistics
|
|
84
|
+
# @param original [String] Original diff
|
|
85
|
+
# @param filtered [String] Filtered diff
|
|
86
|
+
# @return [Hash] Statistics about filtering
|
|
87
|
+
def stats(original, filtered)
|
|
88
|
+
original_stats = Atoms::DiffParser.count_changes(original)
|
|
89
|
+
filtered_stats = Atoms::DiffParser.count_changes(filtered)
|
|
90
|
+
|
|
91
|
+
{
|
|
92
|
+
original: original_stats,
|
|
93
|
+
filtered: filtered_stats,
|
|
94
|
+
files_removed: original_stats[:files] - filtered_stats[:files],
|
|
95
|
+
lines_removed: Atoms::DiffParser.count_lines(original) - Atoms::DiffParser.count_lines(filtered)
|
|
96
|
+
}
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Git
|
|
5
|
+
module Molecules
|
|
6
|
+
# Generate diffs using git commands with configuration options
|
|
7
|
+
# Migrated from ace-git-diff
|
|
8
|
+
class DiffGenerator
|
|
9
|
+
class << self
|
|
10
|
+
# Generate diff based on configuration
|
|
11
|
+
# @param config [Models::DiffConfig] Diff configuration
|
|
12
|
+
# @param executor [Module] Command executor (default: Atoms::CommandExecutor)
|
|
13
|
+
# @return [String] Raw diff output
|
|
14
|
+
def generate(config, executor: Atoms::CommandExecutor)
|
|
15
|
+
# Handle special cases first
|
|
16
|
+
return executor.staged_diff if config.format == :staged
|
|
17
|
+
return executor.working_diff if config.format == :working
|
|
18
|
+
|
|
19
|
+
# Determine what to diff
|
|
20
|
+
range = determine_range(config, executor)
|
|
21
|
+
|
|
22
|
+
# Build git diff command
|
|
23
|
+
cmd_parts = build_command(range, config)
|
|
24
|
+
|
|
25
|
+
# Execute with configured timeout
|
|
26
|
+
result = executor.execute(*cmd_parts, timeout: config.timeout)
|
|
27
|
+
handle_result(result, cmd_parts)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Generate `git diff --numstat` output for structured file statistics.
|
|
31
|
+
# Uses the same range/path/flags logic as the main diff generation path.
|
|
32
|
+
# @param config [Models::DiffConfig] Diff configuration
|
|
33
|
+
# @param executor [Module] Command executor (default: Atoms::CommandExecutor)
|
|
34
|
+
# @return [String] Numstat output
|
|
35
|
+
def generate_numstat(config, executor: Atoms::CommandExecutor)
|
|
36
|
+
range = determine_range(config, executor)
|
|
37
|
+
cmd_parts = build_numstat_command(range, config)
|
|
38
|
+
result = executor.execute(*cmd_parts, timeout: config.timeout)
|
|
39
|
+
handle_result(result, cmd_parts)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Generate diff for a specific range
|
|
43
|
+
# @param range [String] Git range (e.g., "HEAD~5..HEAD", "origin/main...HEAD")
|
|
44
|
+
# @param config [Models::DiffConfig] Configuration options
|
|
45
|
+
# @param executor [Module] Command executor
|
|
46
|
+
# @return [String] Diff output
|
|
47
|
+
def generate_for_range(range, config, executor: Atoms::CommandExecutor)
|
|
48
|
+
cmd_parts = build_command(range, config)
|
|
49
|
+
result = executor.execute(*cmd_parts, timeout: config.timeout)
|
|
50
|
+
handle_result(result, cmd_parts)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Generate diff since a date or commit
|
|
54
|
+
# @param since [String] Date or commit reference
|
|
55
|
+
# @param config [Models::DiffConfig] Configuration options
|
|
56
|
+
# @param executor [Module] Command executor
|
|
57
|
+
# @return [String] Diff output
|
|
58
|
+
def generate_since(since, config, executor: Atoms::CommandExecutor)
|
|
59
|
+
# Resolve since to commit
|
|
60
|
+
since_ref = Atoms::DateResolver.resolve_since_to_commit(since, executor: executor)
|
|
61
|
+
range = "#{since_ref}..HEAD"
|
|
62
|
+
|
|
63
|
+
generate_for_range(range, config, executor: executor)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Get staged diff with configuration
|
|
67
|
+
# @param config [Models::DiffConfig] Configuration options
|
|
68
|
+
# @param executor [Module] Command executor
|
|
69
|
+
# @return [String] Staged diff output
|
|
70
|
+
def staged(config, executor: Atoms::CommandExecutor)
|
|
71
|
+
cmd_parts = build_command("--cached", config)
|
|
72
|
+
result = executor.execute(*cmd_parts, timeout: config.timeout)
|
|
73
|
+
handle_result(result, cmd_parts)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Get working directory diff with configuration
|
|
77
|
+
# @param config [Models::DiffConfig] Configuration options
|
|
78
|
+
# @param executor [Module] Command executor
|
|
79
|
+
# @return [String] Working diff output
|
|
80
|
+
def working(config, executor: Atoms::CommandExecutor)
|
|
81
|
+
cmd_parts = build_command(nil, config)
|
|
82
|
+
result = executor.execute(*cmd_parts, timeout: config.timeout)
|
|
83
|
+
handle_result(result, cmd_parts)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
# Handle command execution result, raising error on failure
|
|
89
|
+
# @param result [Hash] Command execution result with :success, :output, :error keys
|
|
90
|
+
# @param cmd_parts [Array<String>] Command that was executed (for error messages)
|
|
91
|
+
# @return [String] Command output on success
|
|
92
|
+
# @raise [Ace::Git::GitError] When command fails
|
|
93
|
+
def handle_result(result, cmd_parts = nil)
|
|
94
|
+
if result[:success]
|
|
95
|
+
result[:output]
|
|
96
|
+
else
|
|
97
|
+
error_msg = result[:error].to_s.strip
|
|
98
|
+
error_msg = "Unknown git error" if error_msg.empty?
|
|
99
|
+
# Include the failed command for easier debugging
|
|
100
|
+
cmd_str = cmd_parts ? " (#{cmd_parts.join(" ")})" : ""
|
|
101
|
+
raise Ace::Git::GitError, "Git command failed#{cmd_str}: #{error_msg}"
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Determine what range to diff based on configuration and git state
|
|
106
|
+
def determine_range(config, executor)
|
|
107
|
+
# If ranges specified in config, use first non-empty one
|
|
108
|
+
# Empty/blank ranges are treated as "no range" (working tree diff)
|
|
109
|
+
non_empty_ranges = config.ranges.reject { |r| r.nil? || r.strip.empty? }
|
|
110
|
+
return non_empty_ranges.first if non_empty_ranges.any?
|
|
111
|
+
|
|
112
|
+
# If since specified, convert to range
|
|
113
|
+
if config.since
|
|
114
|
+
since_ref = Atoms::DateResolver.resolve_since_to_commit(config.since, executor: executor)
|
|
115
|
+
return "#{since_ref}..HEAD"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Smart default: check if there are unstaged or staged changes
|
|
119
|
+
return nil if executor.has_unstaged_changes?
|
|
120
|
+
return "--cached" if executor.has_staged_changes?
|
|
121
|
+
|
|
122
|
+
# Default: branch diff against origin/main or tracking branch
|
|
123
|
+
tracking = executor.tracking_branch
|
|
124
|
+
if tracking
|
|
125
|
+
return "#{tracking}...HEAD" if executor.respond_to?(:ref_exists?) && executor.ref_exists?(tracking)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
return "origin/main...HEAD" if executor.respond_to?(:ref_exists?) && executor.ref_exists?("origin/main")
|
|
129
|
+
return "HEAD~1..HEAD" if executor.respond_to?(:ref_exists?) && executor.ref_exists?("HEAD~1")
|
|
130
|
+
|
|
131
|
+
nil
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Build git diff command with configuration options
|
|
135
|
+
def build_command(range, config)
|
|
136
|
+
build_git_diff_command(range, config)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def build_numstat_command(range, config)
|
|
140
|
+
build_git_diff_command(range, config, "--numstat")
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def build_git_diff_command(range, config, *extra_flags)
|
|
144
|
+
cmd_parts = ["git", "diff"]
|
|
145
|
+
cmd_parts.concat(extra_flags)
|
|
146
|
+
cmd_parts.concat(config.git_flags)
|
|
147
|
+
cmd_parts << range if range
|
|
148
|
+
|
|
149
|
+
if config.paths.any?
|
|
150
|
+
cmd_parts << "--"
|
|
151
|
+
cmd_parts.concat(config.paths)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
cmd_parts
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|