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,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Git
|
|
5
|
+
module Atoms
|
|
6
|
+
# Group parsed numstat entries by package/layer/root buckets.
|
|
7
|
+
module FileGrouper
|
|
8
|
+
DEFAULT_LAYERS = %w[lib test handbook].freeze
|
|
9
|
+
|
|
10
|
+
class << self
|
|
11
|
+
def group(entries, layers: DEFAULT_LAYERS, dotfile_groups: [])
|
|
12
|
+
normalized_layers = Array(layers).map(&:to_s)
|
|
13
|
+
groups = {}
|
|
14
|
+
|
|
15
|
+
Array(entries).each do |entry|
|
|
16
|
+
group_key, layer_key, display_path = classify(entry, normalized_layers)
|
|
17
|
+
groups[group_key] ||= initialize_group(group_key)
|
|
18
|
+
group = groups[group_key]
|
|
19
|
+
group[:layers][layer_key] ||= initialize_layer(layer_key)
|
|
20
|
+
|
|
21
|
+
normalized_entry = entry.merge(display_path: display_path)
|
|
22
|
+
add_to_group(group, group[:layers][layer_key], normalized_entry)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
ordered_groups = sort_groups(groups.values, dotfile_groups)
|
|
26
|
+
ordered_groups.each { |group| group[:layers] = sort_layers(group[:layers], normalized_layers) }
|
|
27
|
+
|
|
28
|
+
{
|
|
29
|
+
groups: ordered_groups,
|
|
30
|
+
total: {
|
|
31
|
+
additions: ordered_groups.sum { |g| g[:additions] },
|
|
32
|
+
deletions: ordered_groups.sum { |g| g[:deletions] },
|
|
33
|
+
files: ordered_groups.sum { |g| g[:file_count] }
|
|
34
|
+
},
|
|
35
|
+
files: ordered_groups.flat_map { |g| g[:layers].flat_map { |l| l[:files].map { |f| f[:path] } } }
|
|
36
|
+
}
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def initialize_group(name)
|
|
42
|
+
{
|
|
43
|
+
name: name,
|
|
44
|
+
additions: 0,
|
|
45
|
+
deletions: 0,
|
|
46
|
+
file_count: 0,
|
|
47
|
+
layers: {}
|
|
48
|
+
}
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def initialize_layer(name)
|
|
52
|
+
{
|
|
53
|
+
name: name,
|
|
54
|
+
additions: 0,
|
|
55
|
+
deletions: 0,
|
|
56
|
+
file_count: 0,
|
|
57
|
+
files: []
|
|
58
|
+
}
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def add_to_group(group, layer, entry)
|
|
62
|
+
adds = entry[:additions] || 0
|
|
63
|
+
dels = entry[:deletions] || 0
|
|
64
|
+
|
|
65
|
+
group[:additions] += adds
|
|
66
|
+
group[:deletions] += dels
|
|
67
|
+
group[:file_count] += 1
|
|
68
|
+
|
|
69
|
+
layer[:additions] += adds
|
|
70
|
+
layer[:deletions] += dels
|
|
71
|
+
layer[:file_count] += 1
|
|
72
|
+
layer[:files] << entry
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def classify(entry, layers)
|
|
76
|
+
path = entry[:path].to_s
|
|
77
|
+
segments = path.split("/")
|
|
78
|
+
top = segments.first
|
|
79
|
+
|
|
80
|
+
if top&.start_with?("ace-") || (top&.start_with?(".") && segments.length > 1)
|
|
81
|
+
group_name = "#{top}/"
|
|
82
|
+
layer = resolve_layer(segments[1], layers)
|
|
83
|
+
prefix = (layer == "other/" || layer == "root/") ? top : [top, layer.delete_suffix("/")].join("/")
|
|
84
|
+
[group_name, layer, relativize_entry(entry, prefix)]
|
|
85
|
+
else
|
|
86
|
+
["./", "root/", entry[:display_path]]
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def relativize_entry(entry, prefix)
|
|
91
|
+
return entry[:display_path] if prefix.nil? || prefix.empty?
|
|
92
|
+
|
|
93
|
+
if entry[:rename_from] && entry[:rename_to]
|
|
94
|
+
from = trim_prefix(entry[:rename_from], prefix)
|
|
95
|
+
to = trim_prefix(entry[:rename_to], prefix)
|
|
96
|
+
return "#{from} -> #{to}"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
trim_prefix(entry[:display_path], prefix)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def trim_prefix(path, prefix)
|
|
103
|
+
return path if path.nil? || prefix.nil? || prefix.empty?
|
|
104
|
+
|
|
105
|
+
path.start_with?("#{prefix}/") ? path.delete_prefix("#{prefix}/") : path
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def resolve_layer(segment, layers)
|
|
109
|
+
return "root/" if segment.nil? || segment.empty?
|
|
110
|
+
|
|
111
|
+
layers.include?(segment) ? "#{segment}/" : "other/"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def sort_groups(groups, dotfile_groups)
|
|
115
|
+
configured_dot = Array(dotfile_groups).map { |name| name.to_s.end_with?("/") ? name.to_s : "#{name}/" }
|
|
116
|
+
dot_index = configured_dot.each_with_index.to_h
|
|
117
|
+
|
|
118
|
+
groups.sort_by do |group|
|
|
119
|
+
name = group[:name]
|
|
120
|
+
if name == "./"
|
|
121
|
+
[2, 0, name]
|
|
122
|
+
elsif name.start_with?("ace-")
|
|
123
|
+
[0, 0, name]
|
|
124
|
+
elsif name.start_with?(".")
|
|
125
|
+
[1, dot_index.fetch(name, 9_999), name]
|
|
126
|
+
else
|
|
127
|
+
[1, 9_999, name]
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def sort_layers(layers_hash, configured_layers)
|
|
133
|
+
layer_order = configured_layers.each_with_index.map { |layer, idx| ["#{layer}/", idx] }.to_h
|
|
134
|
+
ordered = layers_hash.values.sort_by do |layer|
|
|
135
|
+
key = layer[:name]
|
|
136
|
+
if key == "root/"
|
|
137
|
+
[0, 0, key]
|
|
138
|
+
elsif key == "other/"
|
|
139
|
+
[2, 0, key]
|
|
140
|
+
else
|
|
141
|
+
[1, layer_order.fetch(key, 9_999), key]
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
ordered.each { |layer| layer[:files].sort_by! { |file| file[:display_path] } }
|
|
146
|
+
ordered
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Git
|
|
5
|
+
module Atoms
|
|
6
|
+
# Filters files by git status (staged, tracked, changed)
|
|
7
|
+
# Consolidated from ace-search GitScopeFilter (adapted to use CommandExecutor)
|
|
8
|
+
module GitScopeFilter
|
|
9
|
+
class << self
|
|
10
|
+
# Get files based on git scope
|
|
11
|
+
# @param scope [Symbol] :staged, :tracked, or :changed
|
|
12
|
+
# @param executor [Module] Command executor (default: CommandExecutor)
|
|
13
|
+
# @return [Array<String>] List of file paths
|
|
14
|
+
def get_files(scope, executor: CommandExecutor)
|
|
15
|
+
case scope
|
|
16
|
+
when :staged
|
|
17
|
+
get_staged_files(executor: executor)
|
|
18
|
+
when :tracked
|
|
19
|
+
get_tracked_files(executor: executor)
|
|
20
|
+
when :changed
|
|
21
|
+
get_changed_files(executor: executor)
|
|
22
|
+
else
|
|
23
|
+
[]
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Get staged files
|
|
28
|
+
# @param executor [Module] Command executor
|
|
29
|
+
# @return [Array<String>] List of staged file paths
|
|
30
|
+
def get_staged_files(executor: CommandExecutor)
|
|
31
|
+
result = executor.execute("git", "diff", "--cached", "--name-only")
|
|
32
|
+
return [] unless result[:success]
|
|
33
|
+
|
|
34
|
+
result[:output].lines.map(&:strip).reject(&:empty?)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Get tracked files
|
|
38
|
+
# @param executor [Module] Command executor
|
|
39
|
+
# @return [Array<String>] List of tracked file paths
|
|
40
|
+
def get_tracked_files(executor: CommandExecutor)
|
|
41
|
+
result = executor.execute("git", "ls-files")
|
|
42
|
+
return [] unless result[:success]
|
|
43
|
+
|
|
44
|
+
result[:output].lines.map(&:strip).reject(&:empty?)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Get changed files (modified, not staged)
|
|
48
|
+
# @param executor [Module] Command executor
|
|
49
|
+
# @return [Array<String>] List of changed file paths
|
|
50
|
+
def get_changed_files(executor: CommandExecutor)
|
|
51
|
+
result = executor.execute("git", "diff", "--name-only")
|
|
52
|
+
return [] unless result[:success]
|
|
53
|
+
|
|
54
|
+
result[:output].lines.map(&:strip).reject(&:empty?)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Get both staged and changed files (all uncommitted)
|
|
58
|
+
# @param executor [Module] Command executor
|
|
59
|
+
# @return [Array<String>] List of all uncommitted file paths
|
|
60
|
+
def get_uncommitted_files(executor: CommandExecutor)
|
|
61
|
+
(get_staged_files(executor: executor) + get_changed_files(executor: executor)).uniq
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Check if in git repository
|
|
65
|
+
# @param executor [Module] Command executor
|
|
66
|
+
# @return [Boolean] True if in git repository
|
|
67
|
+
def in_git_repo?(executor: CommandExecutor)
|
|
68
|
+
executor.in_git_repo?
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Get files changed between two refs
|
|
72
|
+
# @param from_ref [String] Start reference (e.g., "origin/main")
|
|
73
|
+
# @param to_ref [String] End reference (default: "HEAD")
|
|
74
|
+
# @param executor [Module] Command executor
|
|
75
|
+
# @return [Array<String>] List of changed file paths
|
|
76
|
+
def get_files_between(from_ref, to_ref = "HEAD", executor: CommandExecutor)
|
|
77
|
+
result = executor.execute("git", "diff", "--name-only", "#{from_ref}...#{to_ref}")
|
|
78
|
+
return [] unless result[:success]
|
|
79
|
+
|
|
80
|
+
result[:output].lines.map(&:strip).reject(&:empty?)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Git
|
|
5
|
+
module Atoms
|
|
6
|
+
# Pure functions for fetching git status information
|
|
7
|
+
# Uses git status -sb for compact, familiar output
|
|
8
|
+
module GitStatusFetcher
|
|
9
|
+
class << self
|
|
10
|
+
# Fetch git status in short branch format
|
|
11
|
+
# @param executor [CommandExecutor] Command executor (default: CommandExecutor)
|
|
12
|
+
# @return [Hash] Result with :success, :output, :error
|
|
13
|
+
def fetch_status_sb(executor: CommandExecutor)
|
|
14
|
+
# Disable color to ensure clean output for LLM context
|
|
15
|
+
result = executor.execute("git", "-c", "color.status=false", "status", "-sb")
|
|
16
|
+
|
|
17
|
+
if result[:success]
|
|
18
|
+
{success: true, output: result[:output].strip}
|
|
19
|
+
else
|
|
20
|
+
{success: false, output: "", error: result[:error]}
|
|
21
|
+
end
|
|
22
|
+
rescue => e
|
|
23
|
+
{success: false, output: "", error: e.message}
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Git
|
|
5
|
+
module Atoms
|
|
6
|
+
# Render grouped numstat data to aligned plain text or markdown.
|
|
7
|
+
module GroupedStatsFormatter
|
|
8
|
+
LAYER_ICONS = {
|
|
9
|
+
"lib/" => "🧱",
|
|
10
|
+
"test/" => "🧪",
|
|
11
|
+
"handbook/" => "📚"
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
def format(grouped_data, markdown: false, collapse_above: 5)
|
|
16
|
+
groups = grouped_data[:groups] || []
|
|
17
|
+
total = grouped_data[:total] || {additions: 0, deletions: 0, files: 0}
|
|
18
|
+
return "" if total[:files].to_i.zero?
|
|
19
|
+
|
|
20
|
+
markdown ? format_markdown(groups, total, collapse_above) : format_plain(groups, total)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def format_plain(groups, total)
|
|
26
|
+
lines = []
|
|
27
|
+
lines << total_line(total)
|
|
28
|
+
lines << ""
|
|
29
|
+
|
|
30
|
+
groups.each do |group|
|
|
31
|
+
lines.concat(render_group_plain(group))
|
|
32
|
+
lines << ""
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
trim_trailing_blank(lines).join("\n")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def format_markdown(groups, total, collapse_above)
|
|
39
|
+
lines = []
|
|
40
|
+
lines << "```text"
|
|
41
|
+
lines << total_line(total)
|
|
42
|
+
lines << "```"
|
|
43
|
+
lines << ""
|
|
44
|
+
|
|
45
|
+
groups.each do |group|
|
|
46
|
+
if group[:file_count] > collapse_above
|
|
47
|
+
lines << "<details>"
|
|
48
|
+
lines << "<summary>#{group[:name]} (#{inline_totals(group)}) - #{group[:file_count]} files</summary>"
|
|
49
|
+
lines << ""
|
|
50
|
+
lines << "```text"
|
|
51
|
+
render_group_plain(group).each { |line| lines << line }
|
|
52
|
+
lines << "```"
|
|
53
|
+
lines << "</details>"
|
|
54
|
+
else
|
|
55
|
+
lines << "```text"
|
|
56
|
+
render_group_plain(group).each { |line| lines << line }
|
|
57
|
+
lines << "```"
|
|
58
|
+
end
|
|
59
|
+
lines << ""
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
trim_trailing_blank(lines).join("\n")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def render_group_plain(group)
|
|
66
|
+
lines = []
|
|
67
|
+
lines << header_line(
|
|
68
|
+
additions: group[:additions],
|
|
69
|
+
deletions: group[:deletions],
|
|
70
|
+
file_count: group[:file_count],
|
|
71
|
+
icon: nil,
|
|
72
|
+
label: group[:name]
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
Array(group[:layers]).each do |layer|
|
|
76
|
+
unless skip_layer_header?(group, layer)
|
|
77
|
+
icon, label = layer_header_parts(layer[:name], group_name: group[:name])
|
|
78
|
+
lines << header_line(
|
|
79
|
+
additions: layer[:additions],
|
|
80
|
+
deletions: layer[:deletions],
|
|
81
|
+
file_count: layer[:file_count],
|
|
82
|
+
icon: icon,
|
|
83
|
+
label: label
|
|
84
|
+
)
|
|
85
|
+
end
|
|
86
|
+
Array(layer[:files]).each_with_index do |file, idx|
|
|
87
|
+
prev_file = layer[:files][idx - 1] if idx > 0
|
|
88
|
+
lines << "#{stats_block(file[:additions], file[:deletions], binary: file[:binary])}#{FILE_INDENT}#{file_line(file, prev_file: prev_file)}"
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
trim_trailing_blank(lines)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def total_line(total)
|
|
96
|
+
"#{stats_block(total[:additions], total[:deletions])}#{files_label(total[:files])}total"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def file_line(file, prev_file: nil)
|
|
100
|
+
suffix = file[:binary] ? " (binary)" : ""
|
|
101
|
+
path = squashed_path(file[:display_path], prev_file&.dig(:display_path))
|
|
102
|
+
"#{path}#{suffix}"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def squashed_path(path, prev_path)
|
|
106
|
+
if path.include?(" -> ")
|
|
107
|
+
return compact_rename_path(path) if prev_path.nil? || !prev_path.include?(" -> ")
|
|
108
|
+
|
|
109
|
+
return squashed_rename_path(path, prev_path)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
return path unless prev_path
|
|
113
|
+
|
|
114
|
+
# Don't compare directory of a rename path — it's not a real filesystem dir
|
|
115
|
+
return path if prev_path.include?(" -> ")
|
|
116
|
+
|
|
117
|
+
curr_dir = File.dirname(path)
|
|
118
|
+
prev_dir = File.dirname(prev_path)
|
|
119
|
+
|
|
120
|
+
return path if curr_dir == "." || curr_dir != prev_dir
|
|
121
|
+
|
|
122
|
+
".../#{File.basename(path)}"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Squash consecutive renames that share from_dir and to_dir.
|
|
126
|
+
# "atoms/old.rb -> atoms/new.rb" becomes " old.rb -> new.rb"
|
|
127
|
+
def squashed_rename_path(path, prev_path)
|
|
128
|
+
return path unless prev_path.include?(" -> ")
|
|
129
|
+
|
|
130
|
+
from, to = path.split(" -> ", 2)
|
|
131
|
+
prev_from, prev_to = prev_path.split(" -> ", 2)
|
|
132
|
+
|
|
133
|
+
from_dir = File.dirname(from)
|
|
134
|
+
to_dir = File.dirname(to)
|
|
135
|
+
|
|
136
|
+
return path unless from_dir == File.dirname(prev_from) && to_dir == File.dirname(prev_to)
|
|
137
|
+
return path if from_dir == "." || to_dir == "."
|
|
138
|
+
|
|
139
|
+
"#{" " * (from_dir.length + 1)}#{File.basename(from)} -> #{" " * (to_dir.length + 1)}#{File.basename(to)}"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Compact shared leading path in a single rename:
|
|
143
|
+
# "a/b/old.rb -> a/b/new.rb" => "a/b/old.rb -> new.rb"
|
|
144
|
+
# "a/b/file -> a/b/_archive/file" => "a/b/file -> _archive/file"
|
|
145
|
+
def compact_rename_path(path)
|
|
146
|
+
from, to = path.split(" -> ", 2)
|
|
147
|
+
return path if from.nil? || to.nil?
|
|
148
|
+
|
|
149
|
+
from_segments = from.split("/")
|
|
150
|
+
to_segments = to.split("/")
|
|
151
|
+
common_segments = []
|
|
152
|
+
|
|
153
|
+
while !from_segments.empty? && !to_segments.empty? && from_segments.first == to_segments.first
|
|
154
|
+
common_segments << from_segments.shift
|
|
155
|
+
to_segments.shift
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
return path if common_segments.empty?
|
|
159
|
+
|
|
160
|
+
common_prefix = common_segments.join("/") + "/"
|
|
161
|
+
from_tail = from.delete_prefix(common_prefix)
|
|
162
|
+
to_tail = to.delete_prefix(common_prefix)
|
|
163
|
+
"#{common_prefix}#{from_tail} -> #{to_tail}"
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Width of a rendered stats block: "%5s, %5s" = 12 chars
|
|
167
|
+
STATS_WIDTH = 12
|
|
168
|
+
# Separator replacing " N files " on file lines (= 3 + 2 + 9 = 14 chars)
|
|
169
|
+
FILES_COLUMN_WIDTH = 14
|
|
170
|
+
ICON_COLUMN = 3
|
|
171
|
+
FILE_INDENT = " " * (FILES_COLUMN_WIDTH + ICON_COLUMN)
|
|
172
|
+
|
|
173
|
+
def stats_block(additions, deletions, binary: false)
|
|
174
|
+
return " " * STATS_WIDTH if binary
|
|
175
|
+
|
|
176
|
+
plus = stat_label(additions, "+")
|
|
177
|
+
minus = stat_label(deletions, "-")
|
|
178
|
+
Kernel.format("%5s, %5s", plus.to_s, minus.to_s)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# " NN files " — always 14 chars (3 + %2d + 9), aligns name column
|
|
182
|
+
def files_label(count)
|
|
183
|
+
Kernel.format(" %2d files ", count.to_i)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def stat_label(value, prefix)
|
|
187
|
+
return nil if value.nil?
|
|
188
|
+
|
|
189
|
+
"#{prefix}#{value}"
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def inline_totals(group)
|
|
193
|
+
add = group[:additions].to_i.positive? ? "+#{group[:additions]}" : "+0"
|
|
194
|
+
del = group[:deletions].to_i.positive? ? "-#{group[:deletions]}" : "-0"
|
|
195
|
+
"#{add}, #{del}"
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def layer_header_parts(name, group_name:)
|
|
199
|
+
layer = name.to_s
|
|
200
|
+
label = (layer == "other/" && group_name.to_s != "./") ? "" : layer
|
|
201
|
+
[LAYER_ICONS[layer], label]
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def icon_column(icon)
|
|
205
|
+
return " " * ICON_COLUMN if icon.nil?
|
|
206
|
+
|
|
207
|
+
"#{icon} "
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def header_line(additions:, deletions:, file_count:, icon:, label:)
|
|
211
|
+
"#{stats_block(additions, deletions)}#{files_label(file_count)}#{icon_column(icon)}#{label}"
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def skip_layer_header?(group, layer)
|
|
215
|
+
group_name = group[:name].to_s
|
|
216
|
+
layer_name = layer[:name].to_s
|
|
217
|
+
|
|
218
|
+
return true if group_name == "./" && layer_name == "root/"
|
|
219
|
+
|
|
220
|
+
group[:additions].to_i == layer[:additions].to_i &&
|
|
221
|
+
group[:deletions].to_i == layer[:deletions].to_i &&
|
|
222
|
+
group[:file_count].to_i == layer[:file_count].to_i
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def trim_trailing_blank(lines)
|
|
226
|
+
lines.pop while lines.last == ""
|
|
227
|
+
lines
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Git
|
|
5
|
+
module Atoms
|
|
6
|
+
# Pure functions for detecting git index lock errors from command output
|
|
7
|
+
#
|
|
8
|
+
# Git operations can fail with "Unable to create .git/index.lock" errors
|
|
9
|
+
# when:
|
|
10
|
+
# - Previous git operations were interrupted (Ctrl+C, crashes, timeouts)
|
|
11
|
+
# - Multiple concurrent operations contend for the same lock
|
|
12
|
+
# - Agents are blocked mid-operation leaving orphan lock files
|
|
13
|
+
#
|
|
14
|
+
# This detector identifies these errors so retry logic can handle them.
|
|
15
|
+
module LockErrorDetector
|
|
16
|
+
# Git error patterns that indicate index lock issues
|
|
17
|
+
# These patterns appear in stderr when git cannot acquire the lock
|
|
18
|
+
# Covers various git versions and platforms
|
|
19
|
+
LOCK_ERROR_PATTERNS = [
|
|
20
|
+
/Unable to create.*index\.lock.*File exists/i,
|
|
21
|
+
/fatal:\s*cannot create.*index\.lock/i,
|
|
22
|
+
/Another git process seems to be running/i,
|
|
23
|
+
/git.*index\.lock.*exists/i,
|
|
24
|
+
/could not open.*index\.lock/i,
|
|
25
|
+
/unable to create.*index\.lock/i,
|
|
26
|
+
/lock file.*index\.lock.*already exists/i
|
|
27
|
+
].freeze
|
|
28
|
+
|
|
29
|
+
# Exit code 128 often indicates lock issues across different git versions
|
|
30
|
+
LOCK_EXIT_CODE = 128
|
|
31
|
+
|
|
32
|
+
class << self
|
|
33
|
+
# Detect if a git error is related to index lock issues
|
|
34
|
+
#
|
|
35
|
+
# @param stderr [String] The error output from a git command
|
|
36
|
+
# @return [Boolean] true if the error indicates a lock issue
|
|
37
|
+
#
|
|
38
|
+
# @example Detect lock error
|
|
39
|
+
# lock_error?("fatal: Unable to create '.git/index.lock': File exists.")
|
|
40
|
+
# # => true
|
|
41
|
+
#
|
|
42
|
+
# @example Non-lock error
|
|
43
|
+
# lock_error?("error: pathspec 'unknown' did not match")
|
|
44
|
+
# # => false
|
|
45
|
+
def lock_error?(stderr)
|
|
46
|
+
return false if stderr.nil? || stderr.empty?
|
|
47
|
+
|
|
48
|
+
LOCK_ERROR_PATTERNS.any? { |pattern| pattern.match?(stderr) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Check if git command result indicates a lock error
|
|
52
|
+
#
|
|
53
|
+
# @param result [Hash] Result hash from CommandExecutor.execute
|
|
54
|
+
# @return [Boolean] true if the result indicates a lock error
|
|
55
|
+
#
|
|
56
|
+
# @example Check result hash
|
|
57
|
+
# result = { success: false, error: "fatal: Unable to create...", exit_code: 128 }
|
|
58
|
+
# lock_error_result?(result)
|
|
59
|
+
# # => true
|
|
60
|
+
def lock_error_result?(result)
|
|
61
|
+
return false if result.nil? || result[:success]
|
|
62
|
+
return false if result[:error].nil? || result[:error].empty?
|
|
63
|
+
|
|
64
|
+
# Primary check: known lock error patterns
|
|
65
|
+
return true if lock_error?(result[:error])
|
|
66
|
+
|
|
67
|
+
# Fallback: exit code 128 with "lock" keyword in error
|
|
68
|
+
# Handles edge cases from different git versions/locales
|
|
69
|
+
if result[:exit_code] == LOCK_EXIT_CODE && result[:error] =~ /lock/i
|
|
70
|
+
return true
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
false
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|