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,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "ace/support/cli"
5
+
6
+ module Ace
7
+ module Git
8
+ module CLI
9
+ module Commands
10
+ # ace-support-cli command for showing PR information
11
+ class Pr < Ace::Support::Cli::Command
12
+ include Ace::Support::Cli::Base
13
+
14
+ desc "Show PR information"
15
+
16
+ argument :number, required: false, desc: "PR number (auto-detected if not provided)"
17
+
18
+ option :format, type: :string, aliases: ["f"], default: "markdown",
19
+ desc: "Output format: markdown, json"
20
+ option :with_diff, type: :boolean, default: false,
21
+ desc: "Include PR diff in output"
22
+
23
+ # Standard options
24
+ option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
25
+ option :verbose, type: :boolean, aliases: %w[-v], desc: "Show verbose output"
26
+ option :debug, type: :boolean, aliases: %w[-d], desc: "Show debug output"
27
+
28
+ def call(number: nil, **options)
29
+ # Check if gh is installed
30
+ unless Molecules::PrMetadataFetcher.gh_installed?
31
+ raise Ace::Support::Cli::Error.new("GitHub CLI (gh) not installed. Install with: brew install gh")
32
+ end
33
+
34
+ # Determine PR identifier
35
+ identifier = if number
36
+ number.to_s
37
+ else
38
+ # Try to find PR for current branch
39
+ found = Molecules::PrMetadataFetcher.find_pr_for_branch
40
+ unless found
41
+ raise Ace::Support::Cli::Error.new("No PR found for current branch. Specify a PR number.")
42
+ end
43
+ found
44
+ end
45
+
46
+ # Fetch PR data
47
+ result = if options[:with_diff]
48
+ Molecules::PrMetadataFetcher.fetch_pr(identifier)
49
+ else
50
+ Molecules::PrMetadataFetcher.fetch_metadata(identifier)
51
+ end
52
+
53
+ unless result[:success]
54
+ raise Ace::Support::Cli::Error.new(result[:error])
55
+ end
56
+
57
+ # Output based on format
58
+ case options[:format]
59
+ when "json"
60
+ output_data = {metadata: result[:metadata]}
61
+ output_data[:diff] = result[:diff] if options[:with_diff]
62
+ puts JSON.pretty_generate(output_data)
63
+ else
64
+ output_markdown(result[:metadata], result[:diff], options)
65
+ end
66
+ rescue Ace::Git::Error => e
67
+ raise Ace::Support::Cli::Error.new(e.message)
68
+ rescue ArgumentError => e
69
+ raise Ace::Support::Cli::Error.new(e.message)
70
+ end
71
+
72
+ private
73
+
74
+ def output_markdown(metadata, diff, options)
75
+ lines = []
76
+
77
+ # Header line: # PR #82: Title... [OPEN]
78
+ header = "# PR ##{metadata["number"]}"
79
+ header += ": #{metadata["title"]}" if metadata["title"]
80
+ header += " [#{metadata["state"]}]" if metadata["state"]
81
+ lines << header
82
+
83
+ # Branch line: Branch: source → target | Draft: No
84
+ branch_parts = []
85
+ if metadata["headRefName"] && metadata["baseRefName"]
86
+ branch_parts << "Branch: #{metadata["headRefName"]} → #{metadata["baseRefName"]}"
87
+ elsif metadata["headRefName"]
88
+ branch_parts << "Branch: #{metadata["headRefName"]}"
89
+ elsif metadata["baseRefName"]
90
+ branch_parts << "Target: #{metadata["baseRefName"]}"
91
+ end
92
+ branch_parts << "Draft: #{metadata["isDraft"] ? "Yes" : "No"}" if metadata.key?("isDraft")
93
+ lines << branch_parts.join(" | ") unless branch_parts.empty?
94
+
95
+ # Author line
96
+ if metadata["author"]
97
+ author = metadata["author"].is_a?(Hash) ? metadata["author"]["login"] : metadata["author"]
98
+ lines << "Author: #{author}"
99
+ end
100
+
101
+ # URL line
102
+ lines << "URL: #{metadata["url"]}" if metadata["url"]
103
+
104
+ if options[:with_diff] && diff
105
+ lines << ""
106
+ lines << "## Diff"
107
+ lines << ""
108
+ lines << "```diff"
109
+ lines << diff
110
+ lines << "```"
111
+ end
112
+
113
+ puts lines.join("\n")
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "ace/support/cli"
5
+
6
+ module Ace
7
+ module Git
8
+ module CLI
9
+ module Commands
10
+ # ace-support-cli command for showing repository status
11
+ class Status < Ace::Support::Cli::Command
12
+ include Ace::Support::Cli::Base
13
+
14
+ desc "Show repository context (branch, PR, activity)"
15
+
16
+ option :format, type: :string, aliases: ["f"], default: "markdown",
17
+ desc: "Output format: markdown, json"
18
+ option :with_diff, type: :boolean, default: false,
19
+ desc: "Include PR diff in output"
20
+ option :no_pr, type: :boolean, default: false, aliases: ["n"],
21
+ desc: "Skip all PR lookups (faster, no network)"
22
+ option :commits, type: :integer, aliases: ["c"],
23
+ desc: "Number of recent commits to show (0 to disable, default: config)"
24
+
25
+ # Standard options
26
+ option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
27
+ option :verbose, type: :boolean, aliases: %w[-v], desc: "Show verbose output"
28
+ option :debug, type: :boolean, aliases: %w[-d], desc: "Show debug output"
29
+
30
+ def call(**options)
31
+ # Determine PR settings based on --no-pr flag
32
+ skip_pr = options[:no_pr]
33
+ commits_limit = options[:commits] || Ace::Git.commits_limit
34
+
35
+ # Load status
36
+ status_options = {
37
+ include_pr: !skip_pr,
38
+ include_pr_activity: !skip_pr,
39
+ include_commits: commits_limit > 0,
40
+ commits_limit: commits_limit,
41
+ timeout: Ace::Git.network_timeout
42
+ }
43
+
44
+ status = Organisms::RepoStatusLoader.load(status_options)
45
+
46
+ # Check for errors
47
+ if status.branch.nil? && status.repository_type == :not_git
48
+ raise Ace::Support::Cli::Error.new("Not in a git repository")
49
+ end
50
+
51
+ # Output based on format
52
+ case options[:format]
53
+ when "json"
54
+ puts JSON.pretty_generate(status.to_h)
55
+ else
56
+ puts status.to_markdown
57
+ end
58
+
59
+ # Include diff if requested
60
+ if options[:with_diff] && status.has_pr?
61
+ begin
62
+ diff_result = Molecules::PrMetadataFetcher.fetch_diff(
63
+ status.pr_metadata["number"].to_s
64
+ )
65
+ if diff_result[:success]
66
+ puts ""
67
+ puts "## PR Diff"
68
+ puts ""
69
+ puts "```diff"
70
+ puts diff_result[:diff]
71
+ puts "```"
72
+ end
73
+ rescue Ace::Git::Error
74
+ # Silently skip diff if it fails
75
+ end
76
+ end
77
+ rescue Ace::Git::Error => e
78
+ raise Ace::Support::Cli::Error.new(e.message)
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/support/cli"
4
+ require "ace/core"
5
+ require_relative "../git"
6
+ require_relative "cli/commands/diff"
7
+ require_relative "cli/commands/status"
8
+ require_relative "cli/commands/branch"
9
+ require_relative "cli/commands/pr"
10
+
11
+ module Ace
12
+ module Git
13
+ # ace-support-cli command registry for ace-git.
14
+ module CLI
15
+ extend Ace::Support::Cli::RegistryDsl
16
+
17
+ # Entry point for CLI invocation (used by tests via cli_helpers)
18
+ #
19
+ # Mirrors the exe's normalized_args logic so in-process tests
20
+ # can exercise range-pattern routing.
21
+ #
22
+ # @param args [Array<String>] Command-line arguments
23
+ def self.start(args)
24
+ Ace::Support::Cli::Runner.new(self).call(args: normalized_args(args))
25
+ end
26
+
27
+ # Normalize arguments to handle empty args and git range patterns.
28
+ # Extracted from exe/ace-git for in-process testability.
29
+ def self.normalized_args(argv)
30
+ return ["--help"] if argv.empty?
31
+ return ["diff"] + argv if git_range_pattern?(argv.first)
32
+
33
+ argv
34
+ end
35
+
36
+ def self.git_range_pattern?(arg)
37
+ return false if arg.nil?
38
+
39
+ return true if arg.match?(/\.\.\.?/) # Range operators: .. or ...
40
+ return true if arg.match?(/[~^]\d*/) # Ref modifiers: ~, ~2, ^, ^2
41
+ return true if arg == "HEAD" # Exact HEAD match
42
+ return true if arg.match?(/@\{/) # Reflog: @{1}, @{yesterday}
43
+
44
+ false
45
+ end
46
+
47
+ private_class_method :git_range_pattern?
48
+
49
+ PROGRAM_NAME = "ace-git"
50
+
51
+ REGISTERED_COMMANDS = [
52
+ ["diff", "Show filtered git diff output"],
53
+ ["status", "Show repository status and PR context"],
54
+ ["branch", "Show current branch information"],
55
+ ["pr", "Show pull request information"]
56
+ ].freeze
57
+
58
+ HELP_EXAMPLES = [
59
+ "ace-git diff --since 7d # Changes from last week",
60
+ "ace-git diff -p 'lib/**' -f summary # Filtered summary",
61
+ "ace-git status --no-pr # Quick status, skip network"
62
+ ].freeze
63
+
64
+ register "diff", Commands::Diff.new
65
+ register "status", Commands::Status.new
66
+ register "branch", Commands::Branch.new
67
+ register "pr", Commands::Pr.new
68
+
69
+ version_cmd = Ace::Support::Cli::VersionCommand.build(
70
+ gem_name: "ace-git",
71
+ version: Ace::Git::VERSION
72
+ )
73
+ register "version", version_cmd
74
+ register "--version", version_cmd
75
+
76
+ help_cmd = Ace::Support::Cli::HelpCommand.build(
77
+ program_name: PROGRAM_NAME,
78
+ version: Ace::Git::VERSION,
79
+ commands: REGISTERED_COMMANDS,
80
+ examples: HELP_EXAMPLES
81
+ )
82
+ register "help", help_cmd
83
+ register "--help", help_cmd
84
+ register "-h", help_cmd
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Git
5
+ module Models
6
+ # Data structure representing diff configuration
7
+ # Migrated from ace-git-diff
8
+ class DiffConfig
9
+ attr_reader :exclude_patterns, :exclude_whitespace, :exclude_renames,
10
+ :exclude_moves, :max_lines, :ranges, :paths, :since, :format, :timeout,
11
+ :grouped_stats_layers, :grouped_stats_collapse_above,
12
+ :grouped_stats_show_full_tree, :grouped_stats_dotfile_groups
13
+
14
+ # @param exclude_patterns [Array<String>] Glob patterns to exclude
15
+ # @param exclude_whitespace [Boolean] Whether to exclude whitespace changes
16
+ # @param exclude_renames [Boolean] Whether to exclude renames
17
+ # @param exclude_moves [Boolean] Whether to exclude moves
18
+ # @param max_lines [Integer] Maximum lines in diff output
19
+ # @param ranges [Array<String>] Git ranges to diff (e.g., ["origin/main...HEAD"])
20
+ # @param paths [Array<String>] Path patterns to include
21
+ # @param since [String] Date or commit to diff from
22
+ # @param format [Symbol] Output format (:diff or :summary)
23
+ # @param timeout [Integer] Command timeout in seconds (default from config)
24
+ # @param grouped_stats_layers [Array<String>] Layer order for grouped-stats output
25
+ # @param grouped_stats_collapse_above [Integer] Markdown collapse threshold
26
+ # @param grouped_stats_show_full_tree [String] Tree rendering mode
27
+ # @param grouped_stats_dotfile_groups [Array<String>] Dot directories to prioritize
28
+ def initialize(
29
+ exclude_patterns: [],
30
+ exclude_whitespace: true,
31
+ exclude_renames: false,
32
+ exclude_moves: false,
33
+ max_lines: 10_000,
34
+ ranges: [],
35
+ paths: [],
36
+ since: nil,
37
+ format: :diff,
38
+ timeout: Ace::Git.git_timeout,
39
+ grouped_stats_layers: %w[lib test handbook],
40
+ grouped_stats_collapse_above: 5,
41
+ grouped_stats_show_full_tree: "collapsible",
42
+ grouped_stats_dotfile_groups: %w[.ace-taskflow .ace]
43
+ )
44
+ @exclude_patterns = Array(exclude_patterns)
45
+ @exclude_whitespace = exclude_whitespace
46
+ @exclude_renames = exclude_renames
47
+ @exclude_moves = exclude_moves
48
+ @max_lines = max_lines
49
+ @ranges = Array(ranges)
50
+ @paths = Array(paths)
51
+ @since = since
52
+ @format = format&.to_sym || :diff
53
+ @timeout = timeout || Ace::Git.git_timeout
54
+ @grouped_stats_layers = Array(grouped_stats_layers).map(&:to_s)
55
+ @grouped_stats_collapse_above = grouped_stats_collapse_above.to_i
56
+ @grouped_stats_show_full_tree = grouped_stats_show_full_tree.to_s
57
+ @grouped_stats_dotfile_groups = Array(grouped_stats_dotfile_groups).map(&:to_s)
58
+ end
59
+
60
+ # Check if diff should exclude whitespace changes
61
+ # @return [Boolean] True if whitespace should be excluded
62
+ def exclude_whitespace?
63
+ @exclude_whitespace
64
+ end
65
+
66
+ # Check if diff should exclude renames
67
+ # @return [Boolean] True if renames should be excluded
68
+ def exclude_renames?
69
+ @exclude_renames
70
+ end
71
+
72
+ # Check if diff should exclude moves
73
+ # @return [Boolean] True if moves should be excluded
74
+ def exclude_moves?
75
+ @exclude_moves
76
+ end
77
+
78
+ # Get git diff command flags based on configuration
79
+ # @return [Array<String>] Command flags
80
+ def git_flags
81
+ flags = []
82
+ flags << "-w" if exclude_whitespace?
83
+ # Use --diff-filter to exclude renames (R) and optionally moves
84
+ # This is more explicit and avoids redundant flags
85
+ # ACDMTUXB includes: Added, Copied, Deleted, Modified, Type, Unmerged, Unknown, Broken
86
+ # When excluding moves, we also disable copy detection (C) as moves are detected as copy+delete
87
+ if exclude_renames? && exclude_moves?
88
+ # Exclude both renames (R) and copies (C) which covers moves
89
+ flags << "--diff-filter=ADMTUXB"
90
+ flags << "-M0" # Disable rename detection entirely
91
+ elsif exclude_renames?
92
+ flags << "--diff-filter=ACDMTUXB"
93
+ elsif exclude_moves?
94
+ # Moves appear as rename with 100% similarity, disable high-similarity renames
95
+ flags << "-M0" # Disable rename detection (moves are renames at 100% similarity)
96
+ end
97
+ flags
98
+ end
99
+
100
+ # Convert to hash representation
101
+ # @return [Hash] Hash representation of the config
102
+ def to_h
103
+ {
104
+ exclude_patterns: exclude_patterns,
105
+ exclude_whitespace: exclude_whitespace?,
106
+ exclude_renames: exclude_renames?,
107
+ exclude_moves: exclude_moves?,
108
+ max_lines: max_lines,
109
+ ranges: ranges,
110
+ paths: paths,
111
+ since: since,
112
+ format: format,
113
+ timeout: timeout,
114
+ grouped_stats: {
115
+ layers: grouped_stats_layers,
116
+ collapse_above: grouped_stats_collapse_above,
117
+ show_full_tree: grouped_stats_show_full_tree,
118
+ dotfile_groups: grouped_stats_dotfile_groups
119
+ }
120
+ }
121
+ end
122
+
123
+ # Known configuration keys
124
+ KNOWN_KEYS = %w[
125
+ exclude_patterns exclude_whitespace exclude_renames exclude_moves
126
+ max_lines ranges paths since format timeout grouped_stats
127
+ ].freeze
128
+
129
+ # Create DiffConfig from hash (e.g., from YAML config)
130
+ # Warns on unknown keys to help users catch typos
131
+ # @param hash [Hash] Configuration hash
132
+ # @return [DiffConfig] New DiffConfig instance
133
+ def self.from_hash(hash)
134
+ return new if hash.nil? || hash.empty?
135
+
136
+ # Warn about unknown keys to help catch typos
137
+ warn_unknown_keys(hash)
138
+
139
+ grouped_stats = hash["grouped_stats"] || hash[:grouped_stats] || {}
140
+
141
+ new(
142
+ exclude_patterns: hash["exclude_patterns"] || hash[:exclude_patterns] || [],
143
+ exclude_whitespace: hash.fetch("exclude_whitespace", hash.fetch(:exclude_whitespace, true)),
144
+ exclude_renames: hash.fetch("exclude_renames", hash.fetch(:exclude_renames, false)),
145
+ exclude_moves: hash.fetch("exclude_moves", hash.fetch(:exclude_moves, false)),
146
+ max_lines: hash.fetch("max_lines", hash.fetch(:max_lines, 10_000)),
147
+ ranges: hash["ranges"] || hash[:ranges] || [],
148
+ paths: hash["paths"] || hash[:paths] || [],
149
+ since: hash["since"] || hash[:since],
150
+ format: hash["format"] || hash[:format] || :diff,
151
+ timeout: hash.fetch("timeout", hash.fetch(:timeout, Ace::Git.git_timeout)),
152
+ grouped_stats_layers: grouped_stats["layers"] || grouped_stats[:layers] || %w[lib test handbook],
153
+ grouped_stats_collapse_above: grouped_stats.fetch("collapse_above", grouped_stats.fetch(:collapse_above, 5)),
154
+ grouped_stats_show_full_tree: grouped_stats["show_full_tree"] || grouped_stats[:show_full_tree] || "collapsible",
155
+ grouped_stats_dotfile_groups: grouped_stats["dotfile_groups"] || grouped_stats[:dotfile_groups] || %w[.ace-taskflow .ace]
156
+ )
157
+ end
158
+
159
+ # Warn about unknown configuration keys
160
+ # @param hash [Hash] Configuration hash
161
+ def self.warn_unknown_keys(hash)
162
+ return if hash.nil? || hash.empty?
163
+
164
+ hash.each_key do |key|
165
+ key_str = key.to_s
166
+ next if KNOWN_KEYS.include?(key_str)
167
+ # Skip nested sections that may be passed through
168
+ next if %w[diff rebase pr squash default_branch remote verbose].include?(key_str)
169
+
170
+ warn "[ace-git] Unknown config key '#{key_str}' in DiffConfig - did you mean one of: #{KNOWN_KEYS.join(", ")}?"
171
+ end
172
+ end
173
+
174
+ # Merge with another config (other takes precedence - complete override)
175
+ # @param other [DiffConfig, Hash] Other config to merge
176
+ # @return [DiffConfig] New merged DiffConfig
177
+ def merge(other)
178
+ other_hash = other.is_a?(DiffConfig) ? other.to_h : other
179
+
180
+ self.class.from_hash(to_h.merge(other_hash))
181
+ end
182
+ end
183
+ end
184
+ end
185
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Git
5
+ module Models
6
+ # Data structure representing the result of a diff operation
7
+ # Migrated from ace-git-diff
8
+ class DiffResult
9
+ attr_reader :content, :stats, :files, :metadata, :filtered
10
+
11
+ # @param content [String] The diff content
12
+ # @param stats [Hash] Statistics about the diff (additions, deletions, files)
13
+ # @param files [Array<String>] List of files in the diff
14
+ # @param metadata [Hash] Additional metadata (range, since, options, etc)
15
+ # @param filtered [Boolean] Whether the diff has been filtered
16
+ def initialize(content:, stats:, files:, metadata: {}, filtered: false)
17
+ @content = content
18
+ @stats = stats
19
+ @files = files
20
+ @metadata = metadata
21
+ @filtered = filtered
22
+ end
23
+
24
+ # Check if the diff is empty
25
+ # @return [Boolean] True if diff has no content
26
+ def empty?
27
+ content.nil? || content.strip.empty?
28
+ end
29
+
30
+ # Check if the diff has changes
31
+ # @return [Boolean] True if diff contains additions or deletions
32
+ def has_changes?
33
+ stats[:total_changes].to_i > 0
34
+ end
35
+
36
+ # Get the number of lines in the diff
37
+ # @return [Integer] Line count
38
+ def line_count
39
+ stats[:line_count] || content&.count("\n")&.+(1) || 0
40
+ end
41
+
42
+ # Get human-readable summary
43
+ # @return [String] Summary string
44
+ def summary
45
+ "#{files.length} files, +#{stats[:additions]} -#{stats[:deletions]}"
46
+ end
47
+
48
+ # Convert to hash representation
49
+ # @return [Hash] Hash representation of the diff result
50
+ def to_h
51
+ {
52
+ content: content,
53
+ stats: stats,
54
+ files: files,
55
+ metadata: metadata,
56
+ filtered: filtered,
57
+ empty: empty?,
58
+ has_changes: has_changes?,
59
+ line_count: line_count,
60
+ summary: summary
61
+ }
62
+ end
63
+
64
+ # Create a DiffResult from parsed diff data
65
+ # @param parsed_data [Hash] Parsed diff data from DiffParser
66
+ # @param metadata [Hash] Additional metadata
67
+ # @param filtered [Boolean] Whether the diff was filtered
68
+ # @return [DiffResult] New DiffResult instance
69
+ def self.from_parsed(parsed_data, metadata: {}, filtered: false)
70
+ new(
71
+ content: parsed_data[:content],
72
+ stats: parsed_data[:stats].merge(line_count: parsed_data[:line_count]),
73
+ files: parsed_data[:files],
74
+ metadata: metadata,
75
+ filtered: filtered
76
+ )
77
+ end
78
+
79
+ # Create an empty DiffResult
80
+ # @param metadata [Hash] Optional metadata
81
+ # @return [DiffResult] Empty DiffResult instance
82
+ def self.empty(metadata: {})
83
+ new(
84
+ content: "",
85
+ stats: {additions: 0, deletions: 0, files: 0, total_changes: 0, line_count: 0},
86
+ files: [],
87
+ metadata: metadata,
88
+ filtered: false
89
+ )
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end