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