ace-git 0.23.0 → 0.24.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.
@@ -4,12 +4,17 @@ module Ace
4
4
  module Git
5
5
  module Organisms
6
6
  # Orchestrates loading complete repository status
7
- # Combines branch info, task pattern detection, and PR metadata
7
+ # Combines branch info, task pattern detection, and PR evidence
8
+ #
9
+ # PR enrichment is provider-optional: it resolves the default forge
10
+ # server and its registered provider via the core contract. With no
11
+ # server configured, no provider registered, or a classified provider
12
+ # failure, status stays purely local and the PR sections are skipped.
8
13
  class RepoStatusLoader
9
14
  class << self
10
15
  # Load complete repository status
11
16
  # @param options [Hash] Options for status loading
12
- # @option options [Boolean] :include_pr Whether to fetch PR metadata (default: true)
17
+ # @option options [Boolean] :include_pr Whether to fetch PR evidence (default: true)
13
18
  # @option options [Boolean] :include_pr_activity Whether to fetch PR activity (default: true)
14
19
  # @option options [Boolean] :include_commits Whether to fetch recent commits (default: true)
15
20
  # @option options [Integer] :commits_limit Number of recent commits to fetch (default: 3)
@@ -53,28 +58,18 @@ module Ace
53
58
  recent_commits = fetch_recent_commits(limit: commits_limit)
54
59
  end
55
60
 
56
- # Fetch PR data (metadata and activity) - parallelized for performance
61
+ # Fetch PR evidence (metadata and activity) via the provider contract
57
62
  pr_metadata = nil
58
63
  pr_activity = nil
59
64
  if !branch_info[:detached]
60
- if include_pr && include_pr_activity
61
- # Both requested: use parallel fetch for ~50% speedup
62
- pr_data = fetch_pr_data_parallel(
63
- current_branch: branch_info[:name],
64
- timeout: timeout
65
- )
66
- pr_metadata = pr_data[:pr_metadata]
67
- pr_activity = pr_data[:pr_activity]
68
- elsif include_pr
69
- # Only PR metadata requested
70
- pr_metadata = fetch_pr_metadata(timeout: timeout)
71
- elsif include_pr_activity
72
- # Only activity requested
73
- pr_activity = fetch_pr_activity(
74
- current_branch: branch_info[:name],
75
- timeout: timeout
76
- )
77
- end
65
+ pr_sections = fetch_pr_sections(
66
+ current_branch: branch_info[:name],
67
+ include_metadata: include_pr,
68
+ include_activity: include_pr_activity,
69
+ timeout: timeout
70
+ )
71
+ pr_metadata = pr_sections[:pr_metadata]
72
+ pr_activity = pr_sections[:pr_activity]
78
73
  end
79
74
 
80
75
  # Build and return status
@@ -100,13 +95,8 @@ module Ace
100
95
  # Get basic status (skip PR activity since we're fetching a specific PR)
101
96
  status = load(include_pr: false, include_pr_activity: false)
102
97
 
103
- # Fetch specific PR metadata
104
- begin
105
- result = Molecules::PrMetadataFetcher.fetch_metadata(pr_identifier, timeout: timeout)
106
- pr_metadata = result[:success] ? result[:metadata] : nil
107
- rescue Ace::Git::Error
108
- pr_metadata = nil
109
- end
98
+ # Fetch specific PR evidence
99
+ pr_metadata = resolve_pr_metadata(pr_identifier, timeout: timeout)
110
100
 
111
101
  # Return status with PR data
112
102
  Models::RepoStatus.from_data(
@@ -131,6 +121,60 @@ module Ace
131
121
 
132
122
  private
133
123
 
124
+ # Resolve a specific PR via the provider contract
125
+ # @return [ProviderPullRequest, nil] PR evidence or nil
126
+ def resolve_pr_metadata(pr_identifier, timeout:)
127
+ provider = active_provider(timeout: timeout)
128
+ return nil unless provider
129
+
130
+ provider.pull_request(number: pr_identifier)
131
+ rescue Ace::Git::Error
132
+ # Classified provider failure: keep status local, skip PR evidence
133
+ nil
134
+ end
135
+
136
+ # Fetch PR evidence sections via the provider contract
137
+ # @return [Hash] {:pr_metadata => ProviderPullRequest, :pr_activity => Hash}
138
+ def fetch_pr_sections(current_branch:, include_metadata:, include_activity:, timeout:)
139
+ provider = active_provider(timeout: timeout)
140
+ return {pr_metadata: nil, pr_activity: nil} unless provider
141
+
142
+ metadata = include_metadata ? provider.pull_request_for_branch(branch: current_branch) : nil
143
+ activity = include_activity ? fetch_activity(provider, current_branch: current_branch) : nil
144
+
145
+ {pr_metadata: metadata, pr_activity: activity}
146
+ rescue Ace::Git::Error
147
+ # Classified provider failure: keep status local, skip PR evidence
148
+ {pr_metadata: nil, pr_activity: nil}
149
+ end
150
+
151
+ # Build merged/open activity lists from recent PR evidence
152
+ def fetch_activity(provider, current_branch:)
153
+ prs = provider.recent_pull_requests(limit: 30)
154
+ merged = prs
155
+ .select { |pr| pr.state == :merged }
156
+ .first(Ace::Git.merged_prs_limit)
157
+ open_prs = prs
158
+ .select { |pr| pr.state == :open && pr.head_ref != current_branch }
159
+ .first(Ace::Git.open_prs_limit)
160
+
161
+ return nil if merged.empty? && open_prs.empty?
162
+
163
+ {merged: merged, open: open_prs}
164
+ end
165
+
166
+ # Resolve the default server's registered provider, or nil.
167
+ # Resolution failures are classified; enrichment is optional, so any
168
+ # of them simply skips the PR sections.
169
+ def active_provider(timeout:)
170
+ server = ServerRegistry.resolve_default
171
+ return nil unless server
172
+
173
+ Providers.for(server, timeout: timeout)
174
+ rescue Ace::Git::Error
175
+ nil
176
+ end
177
+
134
178
  # Fetch git status in short branch format
135
179
  # @return [String, nil] Git status output or nil
136
180
  def fetch_git_status
@@ -149,114 +193,6 @@ module Ace
149
193
  rescue
150
194
  nil
151
195
  end
152
-
153
- # Fetch PR metadata for current branch
154
- # @param timeout [Integer] Timeout in seconds
155
- # @return [Hash, nil] PR metadata or nil
156
- def fetch_pr_metadata(timeout:)
157
- # First try to find PR for current branch
158
- pr_number = Molecules::PrMetadataFetcher.find_pr_for_branch(timeout: timeout)
159
- return nil unless pr_number
160
-
161
- # Then fetch full metadata
162
- result = Molecules::PrMetadataFetcher.fetch_metadata(pr_number, timeout: timeout)
163
- result[:success] ? result[:metadata] : nil
164
- rescue Ace::Git::GhNotInstalledError, Ace::Git::GhAuthenticationError
165
- # gh not available, skip PR metadata
166
- nil
167
- rescue Ace::Git::PrNotFoundError
168
- # No PR for this branch
169
- nil
170
- rescue Ace::Git::TimeoutError
171
- # Timeout, skip PR metadata
172
- nil
173
- rescue
174
- # Any other error, skip PR metadata
175
- nil
176
- end
177
-
178
- # Fetch PR activity (recently merged and open PRs)
179
- # @param current_branch [String] Current branch name to exclude from open PRs
180
- # @param timeout [Integer] Timeout in seconds
181
- # @return [Hash, nil] PR activity with :merged and :open arrays (symbol keys), or nil
182
- # Each PR in the arrays has string keys from JSON parsing: "number", "title", etc.
183
- def fetch_pr_activity(current_branch:, timeout:)
184
- merged_result = Molecules::PrMetadataFetcher.fetch_recently_merged(
185
- limit: Ace::Git.merged_prs_limit,
186
- timeout: timeout
187
- )
188
- open_result = Molecules::PrMetadataFetcher.fetch_open_prs(
189
- exclude_branch: current_branch,
190
- timeout: timeout
191
- )
192
-
193
- # Return nil if both failed
194
- return nil unless merged_result[:success] || open_result[:success]
195
-
196
- # Use symbol keys for outer hash, string keys for PR data (from JSON)
197
- # This is documented behavior - consumers should access via pr_activity[:merged]
198
- # and individual PRs via pr["number"], pr["title"], etc.
199
- {
200
- merged: merged_result[:success] ? merged_result[:prs] : [],
201
- open: open_result[:success] ? open_result[:prs] : []
202
- }
203
- rescue
204
- # Any error, skip PR activity
205
- nil
206
- end
207
-
208
- # Fetch PR metadata and activity in a single API call for optimal performance
209
- # Uses gh pr list --state all to get all PRs, then filters locally
210
- # @param current_branch [String] Current branch name to match/exclude
211
- # @param timeout [Integer] Timeout in seconds
212
- # @return [Hash] Result with :pr_metadata and :pr_activity keys
213
- def fetch_pr_data_parallel(current_branch:, timeout:)
214
- # Single API call gets all recent PRs
215
- result = Molecules::PrMetadataFetcher.fetch_all_prs(
216
- limit: 15, # Enough for current + merged + open
217
- timeout: timeout
218
- )
219
-
220
- return {pr_metadata: nil, pr_activity: nil} unless result[:success]
221
-
222
- prs = result[:prs]
223
-
224
- # Find current PR (matching branch, prefer OPEN > MERGED > CLOSED)
225
- branch_prs = prs.select { |pr| pr["headRefName"] == current_branch }
226
- current_pr = branch_prs.min_by { |pr|
227
- case pr["state"]
228
- when "OPEN" then 0
229
- when "MERGED" then 1
230
- when "CLOSED" then 2
231
- else 3
232
- end
233
- }
234
-
235
- # Get merged PRs (sorted by mergedAt descending, limited)
236
- merged_prs = prs
237
- .select { |pr| pr["state"] == "MERGED" }
238
- .sort_by { |pr| pr["mergedAt"] || "" }
239
- .reverse
240
- .take(Ace::Git.merged_prs_limit)
241
-
242
- # Get open PRs (exclude current branch, limited)
243
- open_prs = prs
244
- .select { |pr| pr["state"] == "OPEN" && pr["headRefName"] != current_branch }
245
- .take(Ace::Git.open_prs_limit)
246
-
247
- # Build activity hash (nil if no activity to show)
248
- pr_activity = nil
249
- if merged_prs.any? || open_prs.any?
250
- pr_activity = {merged: merged_prs, open: open_prs}
251
- end
252
-
253
- {
254
- pr_metadata: current_pr,
255
- pr_activity: pr_activity
256
- }
257
- rescue
258
- {pr_metadata: nil, pr_activity: nil}
259
- end
260
196
  end
261
197
  end
262
198
  end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Git
5
+ module Providers
6
+ # Abstract provider contract implemented by provider packages.
7
+ #
8
+ # The core defines the interface and the normalized evidence types;
9
+ # implementations own all provider CLI invocation, output parsing, and
10
+ # authentication verification. The core itself never executes a provider
11
+ # CLI and never parses provider output.
12
+ #
13
+ # Subclasses receive the resolved server identity and may accept two
14
+ # optional constructor keywords (see {Providers.for}):
15
+ # - timeout: operation timeout in seconds
16
+ # - runner: injectable command runner for tests; a callable receiving
17
+ # keyword arguments (args:, timeout:, env:) and returning a Hash with
18
+ # :stdout, :stderr, :exit_code
19
+ class Base
20
+ attr_reader :server, :timeout
21
+
22
+ # @param server [ResolvedServer] resolved server identity
23
+ # @param timeout [Integer, nil] operation timeout (default: config)
24
+ # @param runner [Proc, nil] injectable command runner for tests
25
+ def initialize(server:, timeout: nil, runner: nil)
26
+ @server = server
27
+ @timeout = timeout || Ace::Git.network_timeout
28
+ @runner = runner
29
+ end
30
+
31
+ # @return [Boolean] true when the provider CLI binary is installed
32
+ def available?
33
+ raise NotImplementedError, "Providers must implement #{self.class}#available?"
34
+ end
35
+
36
+ # @raise [ProviderCliMissingError] when the provider CLI is missing
37
+ def check_available!
38
+ raise NotImplementedError, "Providers must implement #{self.class}#check_available!"
39
+ end
40
+
41
+ # @return [Boolean] true when the provider CLI is authenticated
42
+ def authenticated?
43
+ raise NotImplementedError, "Providers must implement #{self.class}#authenticated?"
44
+ end
45
+
46
+ # @raise [ProviderAuthenticationError] when unauthenticated
47
+ def check_authenticated!
48
+ raise NotImplementedError, "Providers must implement #{self.class}#check_authenticated!"
49
+ end
50
+
51
+ # Fetch one pull request.
52
+ #
53
+ # @param number [Integer, String] pull request number
54
+ # @return [ProviderPullRequest] normalized pull request evidence
55
+ def pull_request(number:)
56
+ raise NotImplementedError, "Providers must implement #{self.class}#pull_request"
57
+ end
58
+
59
+ # Find the pull request associated with a branch.
60
+ #
61
+ # @param branch [String] branch name
62
+ # @return [ProviderPullRequest, nil] evidence, or nil when no pull
63
+ # request is associated with the branch
64
+ def pull_request_for_branch(branch:)
65
+ raise NotImplementedError, "Providers must implement #{self.class}#pull_request_for_branch"
66
+ end
67
+
68
+ # Fetch the full diff of one pull request.
69
+ #
70
+ # @param number [Integer, String] pull request number
71
+ # @return [String] unified diff text
72
+ def pull_request_diff(number:)
73
+ raise NotImplementedError, "Providers must implement #{self.class}#pull_request_diff"
74
+ end
75
+
76
+ # List recent pull requests across states (newest first).
77
+ #
78
+ # @param limit [Integer] maximum number of pull requests
79
+ # @return [Array<ProviderPullRequest>] normalized pull requests
80
+ def recent_pull_requests(limit:)
81
+ raise NotImplementedError, "Providers must implement #{self.class}#recent_pull_requests"
82
+ end
83
+
84
+ # Fetch one issue.
85
+ #
86
+ # @param number [Integer, String] issue number
87
+ # @return [ProviderIssue] normalized issue evidence
88
+ def issue(number:)
89
+ raise NotImplementedError, "Providers must implement #{self.class}#issue"
90
+ end
91
+
92
+ # Fetch check/CI status evidence for a reference.
93
+ #
94
+ # @param ref [String] commit sha or branch name
95
+ # @return [Array<ProviderCheck>] normalized check evidence
96
+ def checks(ref:)
97
+ raise NotImplementedError, "Providers must implement #{self.class}#checks"
98
+ end
99
+
100
+ # Fetch repository metadata for the resolved server.
101
+ #
102
+ # @return [ProviderRepository] normalized repository evidence
103
+ def repository
104
+ raise NotImplementedError, "Providers must implement #{self.class}#repository"
105
+ end
106
+
107
+ private
108
+
109
+ # Command runner: injected fake or nil (implementations use their own
110
+ # executor when nil).
111
+ attr_reader :runner
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Git
5
+ # Normalized evidence types produced by every provider implementation.
6
+ #
7
+ # Provider packages translate their CLI responses into exactly these
8
+ # shapes, so consumers can stay forge-neutral. All fields except the
9
+ # identifying ones may be nil when a provider cannot supply them.
10
+ #
11
+ # Pull request state values: :open, :merged, :closed. Issue state values:
12
+ # :open, :closed.
13
+ ProviderPullRequest = Data.define(
14
+ :server_name, :number, :title, :state, :head_ref, :base_ref,
15
+ :head_sha, :author, :url, :draft, :merged_at
16
+ )
17
+
18
+ ProviderIssue = Data.define(:server_name, :number, :title, :state, :author, :url, :labels)
19
+
20
+ # Normalized check/CI evidence for one check run.
21
+ ProviderCheck = Data.define(:server_name, :name, :state, :conclusion, :url)
22
+
23
+ # Normalized repository evidence.
24
+ ProviderRepository = Data.define(:server_name, :full_name, :default_branch, :url)
25
+ end
26
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+ require_relative "providers/base"
5
+ require_relative "providers/evidence"
6
+
7
+ module Ace
8
+ module Git
9
+ # Registry of provider implementations owned by provider packages.
10
+ #
11
+ # The core never executes provider CLIs. Provider packages register their
12
+ # contract implementation here when loaded; downstream consumers then
13
+ # obtain a provider via {Providers.for}.
14
+ #
15
+ # @example Registering (inside a provider package)
16
+ # Ace::Git::Providers.register(:acme, Ace::Git::Acme::Provider)
17
+ #
18
+ # @example Resolving (in consumers)
19
+ # server = Ace::Git::ServerRegistry.resolve_default
20
+ # provider = Ace::Git::Providers.for(server)
21
+ # pr = provider.pull_request(number: 25)
22
+ module Providers
23
+ @registry = {}
24
+ @mutex = Mutex.new
25
+
26
+ class << self
27
+ # Register a provider implementation for a provider type.
28
+ #
29
+ # @param type [Symbol, String] provider type (e.g. :acme)
30
+ # @param provider_class [Class] class implementing Providers::Base;
31
+ # must respond to .new
32
+ # @raise [ArgumentError] when type or provider_class is invalid
33
+ def register(type, provider_class)
34
+ key = normalize_type(type)
35
+ unless provider_class.respond_to?(:new)
36
+ raise ArgumentError, "Provider for #{key.inspect} must be a class (got #{provider_class.inspect})"
37
+ end
38
+
39
+ @mutex.synchronize { @registry[key] = provider_class }
40
+ provider_class
41
+ end
42
+
43
+ # Build the provider implementation for a resolved server.
44
+ #
45
+ # @param server [ResolvedServer] exactly-resolved server identity
46
+ # @param timeout [Integer, nil] provider operation timeout in seconds
47
+ # @param runner [Proc, nil] optional command runner injection for tests;
48
+ # see Providers::Base
49
+ # @return [Providers::Base] provider instance bound to the server
50
+ # @raise [UnknownProviderError] when no provider is registered for the
51
+ # server's provider type
52
+ def for(server, timeout: nil, runner: nil)
53
+ key = normalize_type(server.provider)
54
+ provider_class = @mutex.synchronize { @registry[key] }
55
+ unless provider_class
56
+ raise UnknownProviderError,
57
+ "No provider registered for type #{key.inspect} (server '#{server.name}'); " \
58
+ "install and require the provider package that owns #{key.inspect}"
59
+ end
60
+
61
+ provider_class.new(server: server, timeout: timeout, runner: runner)
62
+ end
63
+
64
+ # @param type [Symbol, String] provider type
65
+ # @return [Boolean] true when a provider is registered for the type
66
+ def registered?(type)
67
+ key = normalize_type(type)
68
+ @mutex.synchronize { @registry.key?(key) }
69
+ end
70
+
71
+ # @return [Array<Symbol>] registered provider types, sorted
72
+ def types
73
+ @mutex.synchronize { @registry.keys.sort }
74
+ end
75
+
76
+ # Clear all registrations (test seam).
77
+ def reset!
78
+ @mutex.synchronize { @registry.clear }
79
+ end
80
+
81
+ private
82
+
83
+ def normalize_type(type)
84
+ normalized = type.to_s.strip.downcase
85
+ raise ArgumentError, "Provider type must be a non-empty symbol or string" if normalized.empty?
86
+
87
+ normalized.to_sym
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Git
5
+ # An exactly-resolved forge server identity.
6
+ #
7
+ # Produced by ServerRegistry (by explicit name, by default resolution, or by
8
+ # remote URL matching). Carries the configured server name, the provider
9
+ # type that owns its behavior, and the configured base URL.
10
+ #
11
+ # @example
12
+ # ResolvedServer.new(name: "forge-lab", provider: :acme, url: "https://forge.example.com")
13
+ ResolvedServer = Data.define(:name, :provider, :url) do
14
+ # @return [Hash] Plain hash representation
15
+ def to_h
16
+ {name: name, provider: provider, url: url}
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "atoms/command_executor"
4
+ require_relative "atoms/server_url"
5
+ require_relative "errors"
6
+ require_relative "resolved_server"
7
+
8
+ module Ace
9
+ module Git
10
+ # Resolves forge server identity from configuration, deterministically.
11
+ #
12
+ # Configuration lives under the `git.servers` key (see
13
+ # `.ace-defaults/git/config.yml`). Each entry is a uniquely named server:
14
+ #
15
+ # ```yaml
16
+ # servers:
17
+ # - name: forge-lab
18
+ # provider: acme
19
+ # url: https://forge.example.com/owner/repo
20
+ # default: true
21
+ # - name: forge-mirror
22
+ # provider: other
23
+ # url: https://other.example.com/owner/repo
24
+ # ```
25
+ #
26
+ # At most one server may be marked `default: true`. Resolution never falls
27
+ # back silently: every failure raises a classified error from the failure
28
+ # taxonomy.
29
+ class ServerRegistry
30
+ # Internal configured entry: resolved identity plus its default flag.
31
+ Entry = Data.define(:server, :default)
32
+
33
+ class << self
34
+ # All configured servers, in configuration order.
35
+ #
36
+ # @return [Array<ResolvedServer>]
37
+ # @raise [DuplicateServerNameError] when two servers share a name
38
+ # @raise [ConfigError] when an entry is malformed
39
+ def servers
40
+ entries.map(&:server)
41
+ end
42
+
43
+ # Resolve a server by its explicit, configured name.
44
+ #
45
+ # @param name [String, Symbol] configured server name
46
+ # @return [ResolvedServer]
47
+ # @raise [UnknownServerNameError] when no server has that name
48
+ def resolve(name)
49
+ wanted = name.to_s
50
+ entries.map(&:server).find { |server| server.name == wanted } ||
51
+ raise(UnknownServerNameError, "No server named '#{wanted}' is configured " \
52
+ "(configured: #{servers.map(&:name).join(", ")}); add it to git.servers")
53
+ end
54
+
55
+ # Resolve the explicitly configured default server.
56
+ #
57
+ # @return [ResolvedServer]
58
+ # @raise [NoDefaultServerConfiguredError] when zero servers are default
59
+ # @raise [MultipleDefaultServersError] when more than one is default
60
+ def resolve_default
61
+ defaults = entries.select(&:default)
62
+ if defaults.empty?
63
+ raise NoDefaultServerConfiguredError,
64
+ "No default server configured; mark exactly one entry in git.servers with default: true"
65
+ end
66
+ if defaults.size > 1
67
+ raise MultipleDefaultServersError,
68
+ "Multiple servers marked as default: #{defaults.map { |e| e.server.name }.join(", ")}; " \
69
+ "keep exactly one"
70
+ end
71
+
72
+ defaults.first.server
73
+ end
74
+
75
+ # Resolve server identity from a git remote URL, matching configured
76
+ # servers deterministically (no hostname assumptions).
77
+ #
78
+ # @param remote_name [String, nil] git remote name (default: configured
79
+ # `remote`, then "origin")
80
+ # @return [ResolvedServer] the single matching configured server
81
+ # @raise [AmbiguousRemoteError] when the remote matches no configured
82
+ # server, or more than one
83
+ def resolve_remote(remote_name = nil)
84
+ remote_name ||= Ace::Git.config["remote"] || "origin"
85
+ remote_url = read_remote_url(remote_name)
86
+ matches = entries.map(&:server).select { |server| Atoms::ServerUrl.match?(server.url, remote_url) }
87
+
88
+ if matches.empty?
89
+ raise AmbiguousRemoteError,
90
+ "Remote '#{remote_name}' (#{remote_url || "missing"}) matches no configured server " \
91
+ "(configured: #{servers.map(&:name).join(", ")})"
92
+ end
93
+ if matches.size > 1
94
+ raise AmbiguousRemoteError,
95
+ "Remote '#{remote_name}' (#{remote_url}) matches multiple configured servers: " \
96
+ "#{matches.map(&:name).join(", ")}; make server URLs distinct"
97
+ end
98
+
99
+ matches.first
100
+ end
101
+
102
+ private
103
+
104
+ # Validated configured entries, in configuration order.
105
+ def entries
106
+ raw = Ace::Git.config["servers"]
107
+ return [] if raw.nil?
108
+
109
+ raise ConfigError, "servers config must be a list" unless raw.is_a?(Array)
110
+
111
+ list = raw.map { |entry| build_entry(entry) }
112
+ assert_unique_names!(list)
113
+ list
114
+ end
115
+
116
+ # Read a remote URL with local git only (no network, no forge CLI).
117
+ def read_remote_url(remote_name)
118
+ result = Atoms::CommandExecutor.execute("git", "remote", "get-url", remote_name.to_s)
119
+ result[:success] ? result[:output].to_s.strip : nil
120
+ end
121
+
122
+ # Build one internal entry from a config entry, validating structure.
123
+ def build_entry(entry)
124
+ unless entry.is_a?(Hash)
125
+ raise ConfigError, "Each git.servers entry must be a mapping with name, provider, url"
126
+ end
127
+
128
+ normalized = entry.transform_keys(&:to_s)
129
+ %w[name provider url].each do |key|
130
+ next unless normalized[key].nil? || normalized[key].to_s.empty?
131
+
132
+ raise ConfigError, "git.servers entry is missing '#{key}': #{entry.inspect}"
133
+ end
134
+
135
+ Entry.new(
136
+ server: ResolvedServer.new(
137
+ name: normalized["name"].to_s,
138
+ provider: normalized["provider"].to_s.to_sym,
139
+ url: normalized["url"].to_s
140
+ ),
141
+ default: normalized["default"] == true || normalized["default"].to_s == "true"
142
+ )
143
+ end
144
+
145
+ def assert_unique_names!(list)
146
+ names = list.map { |entry| entry.server.name }
147
+ duplicates = names.tally.select { |_name, count| count > 1 }.keys
148
+ return if duplicates.empty?
149
+
150
+ raise DuplicateServerNameError,
151
+ "Duplicate server names in git.servers: #{duplicates.join(", ")}; server names must be unique"
152
+ end
153
+ end
154
+ end
155
+ end
156
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ace
4
4
  module Git
5
- VERSION = "0.23.0"
5
+ VERSION = "0.24.0"
6
6
  end
7
7
  end