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