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,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Git
5
+ module Atoms
6
+ # Pure functions for formatting repository status as various output formats
7
+ # Extracted from Models::RepoStatus.to_markdown for ATOM purity
8
+ module StatusFormatter
9
+ class << self
10
+ # Format repository status as markdown
11
+ # @param status [Models::RepoStatus] Repository status
12
+ # @return [String] Markdown-formatted output
13
+ def to_markdown(status)
14
+ lines = []
15
+ lines << "# Repository Status"
16
+
17
+ # 1. Position section (includes git status -sb)
18
+ lines.concat(format_position_section(status))
19
+
20
+ # 2. Recent commits section
21
+ if status.has_recent_commits?
22
+ lines.concat(format_recent_commits_section(status.recent_commits))
23
+ end
24
+
25
+ # 3. Current PR section (for current branch)
26
+ if status.has_pr?
27
+ lines.concat(format_current_pr_section(status.pr_metadata))
28
+ end
29
+
30
+ # 4. PR Activity section (other PRs)
31
+ if status.has_pr_activity?
32
+ lines.concat(format_pr_activity_section(status.pr_activity))
33
+ end
34
+
35
+ lines.join("\n")
36
+ end
37
+
38
+ # Format position section with raw git status -sb output
39
+ # @param status [Models::RepoStatus] Repository status
40
+ # @return [Array<String>] Lines of markdown
41
+ def format_position_section(status)
42
+ lines = []
43
+ lines << ""
44
+
45
+ # Header with optional task pattern
46
+ header = "## Position"
47
+ header += " (task: #{status.task_pattern})" if status.has_task_pattern?
48
+ lines << header
49
+ lines << ""
50
+
51
+ # Raw git status -sb output
52
+ if status.has_git_status?
53
+ lines << status.git_status_sb
54
+ elsif status.branch
55
+ # Fallback if no git status available
56
+ lines << "Branch: #{status.branch}#{" (detached HEAD)" if status.detached?}"
57
+ end
58
+
59
+ lines
60
+ end
61
+
62
+ # Format recent commits section
63
+ # @param commits [Array<Hash>] Recent commits with :hash and :subject
64
+ # @return [Array<String>] Lines of markdown
65
+ def format_recent_commits_section(commits)
66
+ lines = []
67
+ lines << ""
68
+ lines << "## Recent Commits"
69
+ lines << ""
70
+ commits.each do |commit|
71
+ hash = commit[:hash] || commit["hash"]
72
+ subject = commit[:subject] || commit["subject"]
73
+ lines << "#{hash} #{subject}"
74
+ end
75
+ lines
76
+ end
77
+
78
+ # Format current PR section (highlighted for current branch)
79
+ # @param pr_metadata [Hash] PR metadata
80
+ # @return [Array<String>] Lines of markdown
81
+ def format_current_pr_section(pr_metadata)
82
+ lines = []
83
+ lines << ""
84
+ lines << "## Current PR"
85
+ lines << ""
86
+
87
+ # Main line: #85 [OPEN] Title
88
+ main_line = "##{pr_metadata["number"]}"
89
+ main_line += " [#{pr_metadata["state"]}]" if pr_metadata["state"]
90
+ main_line += " #{pr_metadata["title"]}" if pr_metadata["title"]
91
+ lines << main_line
92
+
93
+ # Details line: Target: main | Author: @username | Draft/Not draft
94
+ details = []
95
+ details << "Target: #{pr_metadata["baseRefName"]}" if pr_metadata["baseRefName"]
96
+ if pr_metadata["author"]
97
+ author = pr_metadata.dig("author", "login") || pr_metadata["author"]
98
+ details << "Author: @#{author}"
99
+ end
100
+ details << (pr_metadata["isDraft"] ? "Draft" : "Not draft") if pr_metadata.key?("isDraft")
101
+ lines << " #{details.join(" | ")}" unless details.empty?
102
+
103
+ # URL line
104
+ lines << " #{pr_metadata["url"]}" if pr_metadata["url"]
105
+
106
+ lines
107
+ end
108
+
109
+ # Format PR activity section
110
+ # @param pr_activity [Hash, nil] PR activity with :merged and :open arrays
111
+ # Each PR in the arrays has string keys from JSON parsing: "number", "title", etc.
112
+ # @return [Array<String>] Lines of markdown
113
+ def format_pr_activity_section(pr_activity)
114
+ lines = []
115
+ lines << ""
116
+ lines << "## PR Activity"
117
+ lines << ""
118
+
119
+ # Handle nil pr_activity for defensive programming
120
+ return lines << "No recent PR activity" if pr_activity.nil?
121
+
122
+ # pr_activity uses symbol keys (from RepoStatusLoader)
123
+ # PR data within uses string keys (from JSON parsing)
124
+ merged = pr_activity[:merged] || []
125
+ open_prs = pr_activity[:open] || []
126
+
127
+ unless merged.empty?
128
+ lines << "Merged:"
129
+ merged.each do |pr|
130
+ title = pr["title"] || "(no title)"
131
+ merged_ago = format_merged_time_compact(pr["mergedAt"])
132
+ lines << " ##{pr["number"]} #{title}#{merged_ago}"
133
+ end
134
+ end
135
+
136
+ unless open_prs.empty?
137
+ lines << "" unless merged.empty? # Add spacing between Merged and Open
138
+ lines << "Open:"
139
+ open_prs.each do |pr|
140
+ title = pr["title"] || "(no title)"
141
+ author = format_author(pr["author"])
142
+ lines << " ##{pr["number"]} #{title}#{author}"
143
+ end
144
+ end
145
+
146
+ if merged.empty? && open_prs.empty?
147
+ lines << "No recent PR activity"
148
+ end
149
+
150
+ lines
151
+ end
152
+
153
+ private
154
+
155
+ # Format merged time as relative string (compact version)
156
+ # @param merged_at [String, nil] ISO8601 timestamp
157
+ # @return [String] Formatted string like " (1h ago)"
158
+ def format_merged_time_compact(merged_at)
159
+ return "" if merged_at.nil? || (merged_at.is_a?(String) && merged_at.empty?)
160
+
161
+ relative = TimeFormatter.relative_time(merged_at)
162
+ relative.empty? ? "" : " (#{relative})"
163
+ end
164
+
165
+ # Format author info
166
+ # @param author [Hash, String, nil] Author data
167
+ # @return [String] Formatted string like " (@username)"
168
+ def format_author(author)
169
+ return "" if author.nil?
170
+
171
+ login = author.is_a?(Hash) ? author["login"] : author.to_s
172
+ return "" if login.nil? || login.empty?
173
+
174
+ " (@#{login})"
175
+ end
176
+ end
177
+ end
178
+ end
179
+ end
180
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Git
5
+ module Atoms
6
+ # Extracts task ID from branch name using configurable patterns
7
+ # Pure function - no I/O operations
8
+ #
9
+ # Consolidated from ace-review TaskAutoDetector
10
+ class TaskPatternExtractor
11
+ # Extract task ID from branch name using configurable patterns
12
+ # @param branch_name [String] e.g., "117-feature-name", "121.01-archive"
13
+ # @param patterns [Array<String>|nil] Optional regex patterns (uses default if nil)
14
+ # @return [String|nil] task ID or nil if not found
15
+ #
16
+ # Default pattern matches:
17
+ # 117-feature -> "117"
18
+ # 121.01-archive -> "121.01"
19
+ # Does not match:
20
+ # main -> nil
21
+ # feature-123 -> nil (number not at start)
22
+ def self.extract_from_branch(branch_name, patterns: nil)
23
+ return nil if branch_name.nil? || branch_name.empty?
24
+ return nil if branch_name == "HEAD" # Detached HEAD state
25
+
26
+ # Use provided patterns or default pattern
27
+ patterns ||= ['^(\d+(?:\.\d+)?)-']
28
+
29
+ patterns.each do |pattern|
30
+ regex = Regexp.new(pattern)
31
+ match = branch_name.match(regex)
32
+ return match[1] if match && match[1]
33
+ rescue RegexpError => e
34
+ warn "Warning: Invalid task pattern '#{pattern}': #{e.message}"
35
+ next
36
+ end
37
+
38
+ nil
39
+ end
40
+
41
+ # Extract task ID using default patterns
42
+ # @param branch_name [String] Branch name to extract from
43
+ # @return [String|nil] Task ID or nil
44
+ def self.extract(branch_name)
45
+ extract_from_branch(branch_name)
46
+ end
47
+
48
+ # Check if branch name contains a task pattern
49
+ # @param branch_name [String] Branch name to check
50
+ # @return [Boolean] True if branch contains task pattern
51
+ def self.has_task_pattern?(branch_name)
52
+ !extract(branch_name).nil?
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Ace
6
+ module Git
7
+ module Atoms
8
+ # Pure functions for formatting timestamps as relative time strings
9
+ # Examples: "2h ago", "1d ago", "3w ago"
10
+ module TimeFormatter
11
+ # Time constants for format_duration calculations
12
+ SECONDS_PER_MINUTE = 60
13
+ MINUTES_PER_HOUR = 60
14
+ HOURS_PER_DAY = 24
15
+ DAYS_PER_WEEK = 7
16
+ DAYS_PER_MONTH = 30 # Simplified (actual avg ~30.44)
17
+ DAYS_PER_YEAR = 365
18
+ MONTHS_PER_YEAR = 12
19
+
20
+ class << self
21
+ # Convert an ISO8601 timestamp to relative time
22
+ # @param timestamp [String, Time] ISO8601 timestamp or Time object
23
+ # @param reference_time [Time] Time to compare against (default: Time.now)
24
+ # @return [String] Relative time string like "2h ago", "1d ago", or empty for future times
25
+ def relative_time(timestamp, reference_time: Time.now)
26
+ return "" if timestamp.nil? || (timestamp.is_a?(String) && timestamp.empty?)
27
+
28
+ time = parse_timestamp(timestamp)
29
+ return "" if time.nil?
30
+
31
+ seconds_ago = (reference_time - time).to_i
32
+ # Don't format future times (negative duration)
33
+ return "" if seconds_ago < 0
34
+
35
+ format_duration(seconds_ago)
36
+ end
37
+
38
+ private
39
+
40
+ # Parse timestamp to Time object
41
+ # @param timestamp [String, Time] Timestamp to parse
42
+ # @return [Time, nil] Parsed time or nil
43
+ def parse_timestamp(timestamp)
44
+ return timestamp if timestamp.is_a?(Time)
45
+
46
+ Time.parse(timestamp)
47
+ rescue ArgumentError, TypeError
48
+ nil
49
+ end
50
+
51
+ # Format duration in seconds as human-readable string
52
+ # @param seconds [Integer] Duration in seconds
53
+ # @return [String] Formatted duration
54
+ def format_duration(seconds)
55
+ return "just now" if seconds < SECONDS_PER_MINUTE
56
+
57
+ minutes = seconds / SECONDS_PER_MINUTE
58
+ return "#{minutes}m ago" if minutes < MINUTES_PER_HOUR
59
+
60
+ hours = minutes / MINUTES_PER_HOUR
61
+ return "#{hours}h ago" if hours < HOURS_PER_DAY
62
+
63
+ days = hours / HOURS_PER_DAY
64
+ return "#{days}d ago" if days < DAYS_PER_WEEK
65
+
66
+ weeks = days / DAYS_PER_WEEK
67
+ return "#{weeks}w ago" if days < DAYS_PER_MONTH
68
+
69
+ # Use months until we hit a full year (365 days)
70
+ # This avoids "0y ago" for 360-364 day intervals
71
+ # Note: Using 30 days/month is a simplification (actual avg is ~30.44)
72
+ # but is acceptable for relative time display purposes
73
+ # Use floor with minimum 1 to avoid "0mo ago" for 30-day intervals
74
+ months = [(days * MONTHS_PER_YEAR.to_f / DAYS_PER_YEAR).floor, 1].max
75
+ return "#{months}mo ago" if days < DAYS_PER_YEAR
76
+
77
+ years = days / DAYS_PER_YEAR
78
+ "#{years}y ago"
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,62 @@
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 branch information
11
+ class Branch < Ace::Support::Cli::Command
12
+ include Ace::Support::Cli::Base
13
+
14
+ desc "Show current branch information"
15
+
16
+ option :format, type: :string, aliases: ["f"], default: "text",
17
+ desc: "Output format: text, json"
18
+
19
+ # Standard options
20
+ option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
21
+ option :verbose, type: :boolean, aliases: %w[-v], desc: "Show verbose output"
22
+ option :debug, type: :boolean, aliases: %w[-d], desc: "Show debug output"
23
+
24
+ def call(**options)
25
+ # Get branch info
26
+ branch_info = Molecules::BranchReader.full_info
27
+
28
+ if branch_info[:error]
29
+ raise Ace::Support::Cli::Error.new(branch_info[:error])
30
+ end
31
+
32
+ # Output based on format
33
+ case options[:format]
34
+ when "json"
35
+ puts JSON.pretty_generate(branch_info)
36
+ else
37
+ output_text(branch_info)
38
+ end
39
+ rescue Ace::Git::Error => e
40
+ raise Ace::Support::Cli::Error.new(e.message)
41
+ end
42
+
43
+ private
44
+
45
+ def output_text(info)
46
+ output = info[:name]
47
+
48
+ if info[:detached]
49
+ output += " (detached HEAD)"
50
+ elsif info[:tracking]
51
+ output += " (tracking: #{info[:tracking]}"
52
+ output += ", #{info[:status_description]}" unless info[:up_to_date]
53
+ output += ")"
54
+ end
55
+
56
+ puts output
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,252 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "tmpdir"
5
+ require "yaml"
6
+ require "ace/support/cli"
7
+
8
+ module Ace
9
+ module Git
10
+ module CLI
11
+ module Commands
12
+ # ace-support-cli command for generating diffs
13
+ # Migrated from ace-git-diff
14
+ class Diff < Ace::Support::Cli::Command
15
+ include Ace::Support::Cli::Base
16
+
17
+ desc "Generate git diff with filtering (default command)"
18
+
19
+ argument :range, required: false, desc: "Git range (e.g., HEAD~5..HEAD, origin/main..HEAD)"
20
+
21
+ option :format, type: :string, aliases: ["f"], default: "diff",
22
+ desc: "Output format: diff, summary, grouped-stats"
23
+ option :since, type: :string, aliases: ["s"],
24
+ desc: "Changes since date/duration (e.g., '7d', '1 week ago')"
25
+ option :paths, type: :array, aliases: ["p"],
26
+ desc: "Include only these glob patterns"
27
+ option :exclude, type: :array, aliases: ["e"],
28
+ desc: "Exclude these glob patterns"
29
+ option :output, type: :string, aliases: ["o"],
30
+ desc: "Write diff to file instead of stdout"
31
+ option :config, type: :string, aliases: ["c"],
32
+ desc: "Load config from specific file"
33
+ option :raw, type: :boolean, default: false,
34
+ desc: "Raw unfiltered output (no exclusions)"
35
+
36
+ # Standard options
37
+ option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
38
+ option :verbose, type: :boolean, aliases: %w[-v], desc: "Show verbose output"
39
+ option :debug, type: :boolean, aliases: %w[-d], desc: "Show debug output"
40
+
41
+ def call(range: nil, **options)
42
+ # Verify we're in a git repository
43
+ unless Atoms::CommandExecutor.in_git_repo?
44
+ raise Ace::Git::GitError, "Not a git repository (or any of the parent directories)"
45
+ end
46
+
47
+ # Build options hash, including any custom config file
48
+ diff_options = build_options(range, options)
49
+
50
+ # Generate diff
51
+ result = if options[:raw]
52
+ Organisms::DiffOrchestrator.raw(diff_options)
53
+ else
54
+ Organisms::DiffOrchestrator.generate(diff_options)
55
+ end
56
+
57
+ # Output result
58
+ output_result(result, options)
59
+
60
+ # Return success
61
+ 0
62
+ rescue Ace::Git::Error => e
63
+ raise Ace::Support::Cli::Error.new(e.message)
64
+ end
65
+
66
+ private
67
+
68
+ def build_options(range, cli_options)
69
+ options = {}
70
+
71
+ # Load custom config file if specified
72
+ if cli_options[:config]
73
+ custom_config = load_config_file(cli_options[:config])
74
+ options.merge!(custom_config)
75
+ end
76
+
77
+ # Add range if specified
78
+ options[:ranges] = [range] if range
79
+
80
+ # Add since if specified
81
+ options[:since] = cli_options[:since] if cli_options[:since]
82
+
83
+ # Add path filters
84
+ options[:paths] = cli_options[:paths] if cli_options[:paths]
85
+
86
+ # Add exclude patterns (overrides config if specified)
87
+ options[:exclude_patterns] = cli_options[:exclude] if cli_options[:exclude]
88
+
89
+ # Add format
90
+ options[:format] = normalized_format(cli_options[:format])
91
+
92
+ options
93
+ end
94
+
95
+ # Load configuration from a YAML file
96
+ # Handles both flat config and git:-rooted config (per .ace/git/config.yml format)
97
+ # @param config_path [String] Path to config file
98
+ # @return [Hash] Configuration hash
99
+ def load_config_file(config_path)
100
+ unless file_exist?(config_path)
101
+ raise Ace::Git::ConfigError, "Config file not found: #{config_path}"
102
+ end
103
+
104
+ config = yaml_safe_load(file_read(config_path))
105
+ config ||= {}
106
+
107
+ # Handle git:-rooted config files (standard .ace/git/config.yml format)
108
+ # Extract git section first, then extract diff config from it
109
+ git_config = config["git"] || config[:git] || config
110
+ Molecules::ConfigLoader.extract_diff_config(git_config)
111
+ rescue Psych::SyntaxError => e
112
+ raise Ace::Git::ConfigError, "Invalid YAML in config file: #{e.message}"
113
+ end
114
+
115
+ def output_result(result, options)
116
+ content = format_content(result, options)
117
+
118
+ # Write to file or stdout
119
+ if options[:output]
120
+ write_to_file(content, options[:output])
121
+ else
122
+ puts content
123
+ end
124
+ end
125
+
126
+ def format_content(result, options)
127
+ if result.empty?
128
+ return "(no changes)"
129
+ end
130
+
131
+ case normalized_format(options[:format])
132
+ when :summary
133
+ format_summary(result)
134
+ when :grouped_stats
135
+ format_grouped_stats(result, output_path: options[:output])
136
+ else
137
+ result.content
138
+ end
139
+ end
140
+
141
+ def write_to_file(content, output_path)
142
+ # Validate path to prevent directory traversal attacks
143
+ validate_output_path(output_path)
144
+
145
+ # Create parent directories if needed
146
+ FileUtils.mkdir_p(File.dirname(output_path)) unless File.dirname(output_path) == "."
147
+
148
+ # Write content to file
149
+ File.write(output_path, content)
150
+
151
+ # Output confirmation to stderr so it doesn't interfere with piping
152
+ warn "Diff written to: #{output_path}"
153
+ end
154
+
155
+ # Validate output path to prevent directory traversal attacks
156
+ # Strictly restricts output paths to current working directory or temp directory
157
+ # Uses File.realpath (when available) to resolve symlinks and normalize paths
158
+ # @param path [String] Path to validate
159
+ # @raise [Ace::Git::ConfigError] If path contains traversal sequences or escapes allowed directories
160
+ def validate_output_path(path)
161
+ # Check for null bytes (security)
162
+ if path.include?("\0")
163
+ raise Ace::Git::ConfigError, "Invalid output path: null bytes not allowed"
164
+ end
165
+
166
+ # Explicitly reject path traversal sequences
167
+ if path.include?("..")
168
+ raise Ace::Git::ConfigError, "Invalid output path: path traversal not allowed"
169
+ end
170
+
171
+ # Resolve the actual path - use realpath for existing paths to resolve symlinks,
172
+ # otherwise use expand_path for new paths (file doesn't exist yet)
173
+ expanded = File.expand_path(path)
174
+
175
+ # Get allowed directories, resolving symlinks where possible
176
+ cwd = resolve_real_path(Dir.pwd)
177
+ tmpdir = resolve_real_path(Dir.tmpdir)
178
+
179
+ # Strictly enforce that path is within cwd or tmpdir (no exceptions)
180
+ unless expanded.start_with?("#{cwd}/") || expanded == cwd ||
181
+ expanded.start_with?("#{tmpdir}/") || expanded == tmpdir
182
+ raise Ace::Git::ConfigError,
183
+ "Invalid output path: must be within working directory or temp directory"
184
+ end
185
+ end
186
+
187
+ # Resolve path to real path (resolving symlinks), falling back to expand_path
188
+ # @param path [String] Path to resolve
189
+ # @return [String] Resolved path
190
+ def resolve_real_path(path)
191
+ File.realpath(path)
192
+ rescue Errno::ENOENT
193
+ File.expand_path(path)
194
+ end
195
+
196
+ def format_summary(result)
197
+ summary = []
198
+ summary << "# Diff Summary"
199
+ summary << ""
200
+ summary << result.summary
201
+ summary << ""
202
+
203
+ if result.files.any?
204
+ summary << "## Files Changed"
205
+ result.files.each do |file|
206
+ summary << "- #{file}"
207
+ end
208
+ summary << ""
209
+ end
210
+
211
+ summary.join("\n")
212
+ end
213
+
214
+ def format_grouped_stats(result, output_path: nil)
215
+ grouped_data = result.metadata[:grouped_stats] || result.metadata["grouped_stats"]
216
+ return result.content unless grouped_data
217
+
218
+ collapse_above = grouped_data[:collapse_above] || grouped_data["collapse_above"] || 5
219
+ markdown = output_path && File.extname(output_path) == ".md"
220
+ Atoms::GroupedStatsFormatter.format(grouped_data, markdown: markdown, collapse_above: collapse_above)
221
+ end
222
+
223
+ def normalized_format(value)
224
+ return :diff if value.nil?
225
+
226
+ value.to_s.tr("-", "_").to_sym
227
+ end
228
+
229
+ protected
230
+
231
+ # Protected methods for external dependency access (testability pattern)
232
+ # See guide://testable-code-patterns for rationale
233
+
234
+ # Check if file exists (protected for test stubbing)
235
+ def file_exist?(path)
236
+ File.exist?(path)
237
+ end
238
+
239
+ # Read file content (protected for test stubbing)
240
+ def file_read(path)
241
+ File.read(path)
242
+ end
243
+
244
+ # Parse YAML safely (protected for test stubbing)
245
+ def yaml_safe_load(content)
246
+ YAML.safe_load(content, permitted_classes: [Symbol])
247
+ end
248
+ end
249
+ end
250
+ end
251
+ end
252
+ end