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