ace-git 0.23.1 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f4630b03ca193f78f73339cb3c2a9b8fbb07c7ca9545b1132cfd66f3e0dda160
4
- data.tar.gz: 60096f6410637684ef3753300a2bf3195a07052170032774be9ea3e66e4c0d5d
3
+ metadata.gz: 746038569079877012656b703209b794e8a055718955f23f8d4b613ec8fb6628
4
+ data.tar.gz: 841b2ac7dc6eed9348e36a46f6abef8e18c8f726e9f26a705d96a3548209ee27
5
5
  SHA512:
6
- metadata.gz: 285125f8923236d4ba4ae5186a20b354eaa978019c5c17eb37fbf6b9bd96057ad0901549f6ae94f690bbc67268dbd7aa692238a9f07a88c8d41fe58c3ad8765d
7
- data.tar.gz: 4d8da76f2676ea2c09434b8a8afa2589a643bd00b5140614286c1be12a5f696adbbd7ce10c079eab35f8efdc24e42ad7fdb8e9e9d5a8bcb58763961c79e9086e
6
+ metadata.gz: b10072d2af5d314c0c00fefc7883ab6d33de5bad5388b549a50db5cc1134b92196d1462c4c598c9aeaa3a79114eb44be2d29693aa3a95db74646bd4786dd6131
7
+ data.tar.gz: 510071cb8c141cde868bcc0fb79d55e62edbf749c5b82dfbb987488cfa20fcd236fc7c2c5c963db3891655bcbef8bcf9223634298a5abeb0236c4518f9ecf6be
@@ -10,7 +10,22 @@ git:
10
10
 
11
11
  # Timeout settings (in seconds)
12
12
  timeout: 30 # Local git operations (diff, status, log)
13
- network_timeout: 60 # Network operations (gh CLI, remote operations)
13
+ network_timeout: 60 # Network operations (provider CLIs, remote operations)
14
+
15
+ # Forge server registry (provider-neutral)
16
+ # Configure each forge server once with a unique name; mark at most one as
17
+ # default. Resolve by explicit name, by default, or by matching a git remote
18
+ # URL. The `provider` value selects the registered provider package that owns
19
+ # that server's behavior.
20
+ servers: []
21
+ # servers:
22
+ # - name: forgejo-lab
23
+ # provider: forgejo
24
+ # url: https://forgejo.example.com/owner/repo
25
+ # default: true
26
+ # - name: github-public
27
+ # provider: github
28
+ # url: https://github.example.com/owner/repo
14
29
 
15
30
  # Status command settings
16
31
  status:
data/CHANGELOG.md CHANGED
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.24.0] - 2026-09-21
11
+
12
+ ### Added
13
+
14
+ - Forge-neutral provider foundation: `Ace::Git::ServerRegistry` (explicit named servers, exactly-one-default resolution, deterministic remote-URL matching with no hostname assumptions), `Ace::Git::ResolvedServer`, the `Ace::Git::Providers` registry with the `Providers::Base` contract, normalized evidence types (`ProviderPullRequest`, `ProviderIssue`, `ProviderCheck`, `ProviderRepository`), and the neutral `Atoms::PrIdentifier` / `Atoms::ServerUrl` atoms.
15
+ - Classified failure taxonomy in `git/errors.rb`: `NoDefaultServerConfiguredError`, `MultipleDefaultServersError`, `DuplicateServerNameError`, `UnknownProviderError`, `UnknownServerNameError`, `AmbiguousRemoteError`, `ProviderCliMissingError`, `ProviderAuthenticationError`, `ProviderUnreachableError`, `ProviderMalformedOutputError`, `ProviderObjectNotFoundError`.
16
+ - `git.servers` configuration section supporting multiple uniquely named servers with at most one explicit default.
17
+ - Boundary proof: local Git operations succeed with provider CLIs scrubbed from PATH, and a test pins the core lib to zero forge CLI references.
18
+
19
+ ### Changed
20
+
21
+ - Status PR enrichment routes through the provider contract (default server + registered provider); with no server or provider configured, status stays purely local and PR sections are skipped.
22
+ - Forge server URLs in gemspec metadata point at the canonical delivery forge instead of hardcoded GitHub URLs.
23
+
24
+ ### Removed
25
+
26
+ - All forge behavior from the core (pre-1.0 replacement, no shims or aliases): `GhCliExecutor`, `PrMetadataFetcher`, `GithubIssueSync`, the `ace-git pr` CLI command, the GitHub-specific `PrIdentifierParser`, and the `GhNotInstalledError` / `GhAuthenticationError` / `PrNotFoundError` error classes. GitHub behavior moved to the new `ace-git-github` gem; Forgejo behavior is provided by the new `ace-git-forgejo` gem.
10
27
 
11
28
  ## [0.23.1] - 2026-09-02
12
29
 
@@ -17,7 +17,8 @@ guided workflow.
17
17
  - Ruby 3.2+
18
18
  - Git 2.23+
19
19
  - `ace-git` installed
20
- - Optional: GitHub CLI (`gh`) for PR-aware commands and workflows
20
+ - Optional: a provider package (`ace-git-github` or `ace-git-forgejo`) plus a configured
21
+ default forge server for PR-aware status enrichment
21
22
 
22
23
  ## Installation
23
24
 
@@ -76,7 +77,6 @@ Use this for preferences, not for redefining the workflow logic.
76
77
  | Show repo context | `ace-git status` |
77
78
  | Show a quick diff summary | `ace-git diff --format summary` |
78
79
  | Show branch tracking info | `ace-git branch` |
79
- | Show PR metadata | `ace-git pr` |
80
80
  | Show package version | `ace-git version` |
81
81
 
82
82
  ## Run Package Tests
@@ -90,7 +90,7 @@ Use the restarted package test model:
90
90
 
91
91
  ## Next Steps
92
92
 
93
- - Load `wfi://github/pr/create` to open a structured PR workflow
94
- - Load `wfi://github/pr/update` to refresh an existing PR description
95
93
  - Load `wfi://git/reorganize-commits` to clean up branch history before review
94
+ - Install a provider package (`ace-git-github` / `ace-git-forgejo`) and configure
95
+ `git.servers` for PR-aware status enrichment
96
96
  - Run `ace-git --help` to browse command-level examples
data/docs/usage.md CHANGED
@@ -23,12 +23,11 @@ gem install ace-git
23
23
 
24
24
  ## Command Overview
25
25
 
26
- `ace-git` ships five commands:
26
+ `ace-git` ships four commands:
27
27
 
28
28
  - `diff` for filtered or formatted git diffs
29
29
  - `status` for repository context and PR activity
30
30
  - `branch` for current branch and tracking state
31
- - `pr` for PR metadata lookup
32
31
  - `version` for the installed package version
33
32
 
34
33
  `ace-git` with no arguments shows help. Git range shorthand such as `HEAD~5..HEAD` routes to `diff`.
@@ -155,43 +154,6 @@ ace-git branch --format json
155
154
  **Options:**
156
155
  - `--format, -f` - Output format: `text` (default), `json`
157
156
 
158
- ### `ace-git pr [NUMBER]`
159
-
160
- Fetch and display PR metadata using GitHub CLI.
161
-
162
- ```bash
163
- # Auto-detect PR from current branch
164
- ace-git pr
165
-
166
- # Specific PR number
167
- ace-git pr 123
168
-
169
- # Cross-repository PR
170
- ace-git pr owner/repo#456
171
-
172
- # JSON output
173
- ace-git pr --format json
174
- ```
175
-
176
- **Options:**
177
- - `--format, -f` - Output format: `markdown` (default), `json`
178
- - `--with-diff` - Include PR diff in output
179
-
180
- **PR Number Formats:**
181
- - Simple number: `123`
182
- - Qualified: `owner/repo#456`
183
- - GitHub URL: `https://github.com/owner/repo/pull/789`
184
-
185
- **Requirements:** GitHub CLI (`gh`) must be installed and authenticated.
186
-
187
- ```bash
188
- # Install gh
189
- brew install gh
190
-
191
- # Authenticate
192
- gh auth login
193
- ```
194
-
195
157
  ### `ace-git version`
196
158
 
197
159
  Print the installed `ace-git` version.
@@ -267,11 +229,18 @@ Note: `test/**/*` and `spec/**/*` are NOT excluded by default - test changes are
267
229
  - **ace-bundle** - Load ACE workflow instructions directly
268
230
  - **ace-nav** - Discover workflow and template protocol paths
269
231
 
270
- ## Reusable GitHub Issue Primitives
232
+ ## Provider Packages
271
233
 
272
- `ace-git` also exposes reusable library primitives for issue synchronization flows used by other ACE packages:
234
+ `ace-git` is forge-neutral. It defines the server registry (`Ace::Git::ServerRegistry`),
235
+ the provider contract (`Ace::Git::Providers::Base`), and normalized evidence types;
236
+ provider packages own all forge CLI behavior:
273
237
 
274
- - `Ace::Git::Molecules::GhCliExecutor` - shared `gh` command execution with timeout and auth/install error handling
275
- - `Ace::Git::Molecules::GithubIssueSync.sync_task(...)` - sticky comment upsert, `ace:tracked` label management, and close/reopen lifecycle actions
238
+ - **ace-git-github** - GitHub behavior via the `gh` CLI (PR lookups, diffs, issue
239
+ synchronization). Registers under the `github` provider type.
240
+ - **ace-git-forgejo** - Forgejo behavior via the `fj` CLI. Registers under the
241
+ `forgejo` provider type.
276
242
 
277
- These are intended for package integrations such as `ace-task` and are not invoked directly through `ace-git` CLI commands.
243
+ Configure servers in `.ace/git.yml` (or project config) under `git.servers`, then
244
+ resolve them by name, by default, or from a git remote URL. Every failure path is
245
+ classified (missing CLI, unauthenticated, unreachable, malformed output, object
246
+ not found) with no silent fallbacks.
@@ -106,7 +106,7 @@ module Ace
106
106
  # Using Timeout to prevent hanging on network issues or stuck git operations
107
107
  Timeout.timeout(timeout) do
108
108
  # Using Open3.capture3 to avoid shell injection
109
- # Arguments are passed directly as array elements, not through shell
109
+ # Arguments are passed directly as array elements, never via a shell
110
110
  # If env is provided, prepend it to the command (Open3 convention)
111
111
  args = env ? [env, *command_parts] : command_parts
112
112
  stdout, stderr, status = Open3.capture3(*args)
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Git
5
+ module Atoms
6
+ # Parse provider-neutral pull request identifiers into structured form.
7
+ #
8
+ # Supports the forge-neutral forms:
9
+ # - Simple number: "123"
10
+ # - Qualified reference: "owner/repo#456"
11
+ #
12
+ # Provider-specific identifier formats (for example full web URLs) belong
13
+ # to the provider packages, not to the core.
14
+ module PrIdentifier
15
+ # Parsed identifier result
16
+ ParseResult = Data.define(:number, :repo)
17
+
18
+ # Maximum identifier length to bound regex work
19
+ MAX_IDENTIFIER_LENGTH = 256
20
+
21
+ # Parse a pull request identifier string.
22
+ #
23
+ # Returns nil for nil/empty input (no PR specified), raises ArgumentError
24
+ # for invalid formats. This lets callers distinguish "no PR" (nil input)
25
+ # from "invalid PR" (bad format).
26
+ #
27
+ # @param input [String, Integer] pull request identifier
28
+ # @return [ParseResult, nil] parsed identifier, or nil for nil/empty input
29
+ # @raise [ArgumentError] if identifier format is invalid
30
+ #
31
+ # @example Simple number
32
+ # parse(123) # => ParseResult(number: "123", repo: nil)
33
+ #
34
+ # @example Qualified reference
35
+ # parse("owner/repo#456") # => ParseResult(number: "456", repo: "owner/repo")
36
+ #
37
+ # @example Nil/empty input
38
+ # parse(nil) # => nil
39
+ def self.parse(input)
40
+ return nil if input.nil?
41
+
42
+ input_str = input.to_s.strip
43
+ return nil if input_str.empty?
44
+
45
+ if input_str.length > MAX_IDENTIFIER_LENGTH
46
+ raise ArgumentError, "PR identifier too long (max #{MAX_IDENTIFIER_LENGTH} characters)"
47
+ end
48
+
49
+ case input_str
50
+ when /\A(\d+)\z/
51
+ number = ::Regexp.last_match(1)
52
+ raise ArgumentError, "Invalid PR identifier format: #{input_str}" if number.to_i.zero?
53
+
54
+ # Canonical form (no leading zeros) for consistent downstream use
55
+ ParseResult.new(number: number.to_i.to_s, repo: nil)
56
+ when /\A(?<repo>[a-zA-Z0-9_\-.]+\/[a-zA-Z0-9_\-.]+)#(?<number>\d+)\z/
57
+ match = ::Regexp.last_match
58
+ ParseResult.new(number: match[:number], repo: match[:repo])
59
+ else
60
+ raise ArgumentError, "Invalid PR identifier format: #{input_str}"
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Git
5
+ module Atoms
6
+ # Normalize forge server URLs and remote URLs to a canonical comparable
7
+ # form. Purely textual: no network access, no hostname allow-lists, and
8
+ # no assumptions about any particular forge host.
9
+ #
10
+ # Supported input shapes:
11
+ # - "https://forge.example.com/owner/repo.git"
12
+ # - "http://forge.internal:3000/owner/repo"
13
+ # - "git@forge.example.com:owner/repo.git" (scp-style)
14
+ # - "ssh://git@forge.example.com:2222/owner/repo.git"
15
+ # - "forge.example.com/owner/repo"
16
+ #
17
+ # Canonical form: lowercase "host[:port]/path" with any credentials,
18
+ # scheme, trailing slash, and ".git" suffix removed. A colon segment is
19
+ # treated as a port (not an scp separator) only when it is all digits and
20
+ # followed by a slash; everything else is scp-style "host:path".
21
+ module ServerUrl
22
+ # Normalize a server or remote URL to the canonical comparable form.
23
+ #
24
+ # @param url [String] server or remote URL
25
+ # @return [String] canonical "host[:port]/path" form
26
+ def self.normalize(url)
27
+ text = url.to_s.strip
28
+ text = text.sub(%r{\A[a-z][a-z0-9+.\-]*://}i, "")
29
+ text = text.sub(%r{\A[^/@]+@}, "")
30
+
31
+ if (port_match = text.match(%r{\A([^/:]+):(\d+)(/.+)\z}))
32
+ text = "#{port_match[1]}:#{port_match[2]}#{port_match[3]}"
33
+ elsif (scp_match = text.match(%r{\A([^/:]+):(.+)\z}))
34
+ text = "#{scp_match[1]}/#{scp_match[2]}"
35
+ end
36
+
37
+ text = text.sub(%r{/?\.git/?\z}i, "")
38
+ text.chomp("/").downcase
39
+ end
40
+
41
+ # Compare a configured server URL against a remote URL for identity.
42
+ #
43
+ # @param server_url [String] configured server base URL
44
+ # @param remote_url [String] git remote URL
45
+ # @return [Boolean] true when both normalize to the same form
46
+ def self.match?(server_url, remote_url)
47
+ normalized_server = normalize(server_url)
48
+ normalized_remote = normalize(remote_url)
49
+ return false if normalized_server.empty? || normalized_remote.empty?
50
+
51
+ normalized_server == normalized_remote
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -75,40 +75,37 @@ module Ace
75
75
  lines
76
76
  end
77
77
 
78
- # Format current PR section (highlighted for current branch)
79
- # @param pr_metadata [Hash] PR metadata
78
+ # Format current PR section (spotlit for current branch)
79
+ # @param pr [ProviderPullRequest, nil] normalized pull request evidence
80
80
  # @return [Array<String>] Lines of markdown
81
- def format_current_pr_section(pr_metadata)
81
+ def format_current_pr_section(pr)
82
82
  lines = []
83
83
  lines << ""
84
84
  lines << "## Current PR"
85
85
  lines << ""
86
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"]
87
+ # Main line: #85 [open] Title
88
+ main_line = "##{pr.number}"
89
+ main_line += " [#{pr.state}]" if pr.state
90
+ main_line += " #{pr.title}" if pr.title
91
91
  lines << main_line
92
92
 
93
93
  # Details line: Target: main | Author: @username | Draft/Not draft
94
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")
95
+ details << "Target: #{pr.base_ref}" if pr.base_ref
96
+ details << "Author: @#{pr.author}" if pr.author
97
+ details << (pr.draft ? "Draft" : "Not draft") unless pr.draft.nil?
101
98
  lines << " #{details.join(" | ")}" unless details.empty?
102
99
 
103
100
  # URL line
104
- lines << " #{pr_metadata["url"]}" if pr_metadata["url"]
101
+ lines << " #{pr.url}" if pr.url
105
102
 
106
103
  lines
107
104
  end
108
105
 
109
106
  # 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.
107
+ # @param pr_activity [Hash, nil] Activity with :merged and :open arrays of
108
+ # normalized ProviderPullRequest evidence
112
109
  # @return [Array<String>] Lines of markdown
113
110
  def format_pr_activity_section(pr_activity)
114
111
  lines = []
@@ -119,17 +116,15 @@ module Ace
119
116
  # Handle nil pr_activity for defensive programming
120
117
  return lines << "No recent PR activity" if pr_activity.nil?
121
118
 
122
- # pr_activity uses symbol keys (from RepoStatusLoader)
123
- # PR data within uses string keys (from JSON parsing)
124
119
  merged = pr_activity[:merged] || []
125
120
  open_prs = pr_activity[:open] || []
126
121
 
127
122
  unless merged.empty?
128
123
  lines << "Merged:"
129
124
  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}"
125
+ title = pr.title || "(no title)"
126
+ merged_ago = format_merged_time_compact(pr.merged_at)
127
+ lines << " ##{pr.number} #{title}#{merged_ago}"
133
128
  end
134
129
  end
135
130
 
@@ -137,9 +132,9 @@ module Ace
137
132
  lines << "" unless merged.empty? # Add spacing between Merged and Open
138
133
  lines << "Open:"
139
134
  open_prs.each do |pr|
140
- title = pr["title"] || "(no title)"
141
- author = format_author(pr["author"])
142
- lines << " ##{pr["number"]} #{title}#{author}"
135
+ title = pr.title || "(no title)"
136
+ author = format_author(pr.author)
137
+ lines << " ##{pr.number} #{title}#{author}"
143
138
  end
144
139
  end
145
140
 
@@ -163,13 +158,13 @@ module Ace
163
158
  end
164
159
 
165
160
  # Format author info
166
- # @param author [Hash, String, nil] Author data
161
+ # @param author [String, nil] Author identifier
167
162
  # @return [String] Formatted string like " (@username)"
168
163
  def format_author(author)
169
164
  return "" if author.nil?
170
165
 
171
- login = author.is_a?(Hash) ? author["login"] : author.to_s
172
- return "" if login.nil? || login.empty?
166
+ login = author.to_s
167
+ return "" if login.empty?
173
168
 
174
169
  " (@#{login})"
175
170
  end
@@ -59,24 +59,34 @@ module Ace
59
59
  # Include diff if requested
60
60
  if options[:with_diff] && status.has_pr?
61
61
  begin
62
- diff_result = Molecules::PrMetadataFetcher.fetch_diff(
63
- status.pr_metadata["number"].to_s
64
- )
65
- if diff_result[:success]
62
+ provider = active_provider
63
+ diff = provider&.pull_request_diff(number: status.pr_metadata.number.to_s)
64
+ if diff
66
65
  puts ""
67
66
  puts "## PR Diff"
68
67
  puts ""
69
68
  puts "```diff"
70
- puts diff_result[:diff]
69
+ puts diff
71
70
  puts "```"
72
71
  end
73
72
  rescue Ace::Git::Error
74
- # Silently skip diff if it fails
73
+ # Classified provider failure: skip the diff section
75
74
  end
76
75
  end
77
76
  rescue Ace::Git::Error => e
78
77
  raise Ace::Support::Cli::Error.new(e.message)
79
78
  end
79
+
80
+ private
81
+
82
+ # Resolve the default server's registered provider for optional
83
+ # enrichment; nil when no server or provider is configured.
84
+ def active_provider
85
+ server = Ace::Git::ServerRegistry.resolve_default
86
+ Ace::Git::Providers.for(server)
87
+ rescue Ace::Git::Error
88
+ nil
89
+ end
80
90
  end
81
91
  end
82
92
  end
data/lib/ace/git/cli.rb CHANGED
@@ -6,7 +6,6 @@ require_relative "../git"
6
6
  require_relative "cli/commands/diff"
7
7
  require_relative "cli/commands/status"
8
8
  require_relative "cli/commands/branch"
9
- require_relative "cli/commands/pr"
10
9
 
11
10
  module Ace
12
11
  module Git
@@ -51,8 +50,7 @@ module Ace
51
50
  REGISTERED_COMMANDS = [
52
51
  ["diff", "Show filtered git diff output"],
53
52
  ["status", "Show repository status and PR context"],
54
- ["branch", "Show current branch information"],
55
- ["pr", "Show pull request information"]
53
+ ["branch", "Show current branch information"]
56
54
  ].freeze
57
55
 
58
56
  HELP_EXAMPLES = [
@@ -64,7 +62,6 @@ module Ace
64
62
  register "diff", Commands::Diff.new
65
63
  register "status", Commands::Status.new
66
64
  register "branch", Commands::Branch.new
67
- register "pr", Commands::Pr.new
68
65
 
69
66
  version_cmd = Ace::Support::Cli::VersionCommand.build(
70
67
  gem_name: "ace-git",
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Git
5
+ # Error handling pattern:
6
+ # - Atoms: Pure functions, raise exceptions for invalid inputs
7
+ # - Molecules: May return error hashes for "expected" errors (e.g., not in git repo)
8
+ # or raise exceptions for unexpected failures
9
+ # - Organisms: Orchestrate molecules, propagate or wrap exceptions
10
+ # - Commands: Catch exceptions and return exit codes (0=success, 1=error)
11
+ #
12
+ # All custom exceptions inherit from Ace::Git::Error for consistent catching.
13
+ class Error < StandardError; end
14
+ class GitError < Error; end
15
+ class ConfigError < Error; end
16
+ class TimeoutError < Error; end
17
+
18
+ # ---- Forge server resolution failures ----
19
+
20
+ # Default requested but no default server is configured.
21
+ class NoDefaultServerConfiguredError < Error; end
22
+
23
+ # More than one server is marked as default in configuration.
24
+ class MultipleDefaultServersError < Error; end
25
+
26
+ # Multiple servers are configured with the same identifier.
27
+ class DuplicateServerNameError < Error; end
28
+
29
+ # A server references a provider type that no provider package registered.
30
+ class UnknownProviderError < Error; end
31
+
32
+ # The requested server name is not present in configuration.
33
+ class UnknownServerNameError < Error; end
34
+
35
+ # A remote URL matches multiple configured servers, or none, so the server
36
+ # identity cannot be determined without caller input.
37
+ class AmbiguousRemoteError < Error; end
38
+
39
+ # ---- Provider interaction failures ----
40
+
41
+ # The provider CLI binary is not installed in PATH.
42
+ class ProviderCliMissingError < Error; end
43
+
44
+ # The provider CLI is unauthenticated or its credential lease expired.
45
+ class ProviderAuthenticationError < Error; end
46
+
47
+ # The remote forge endpoint is offline or unreachable.
48
+ class ProviderUnreachableError < Error; end
49
+
50
+ # The provider CLI returned invalid JSON or an unexpected schema.
51
+ class ProviderMalformedOutputError < Error; end
52
+
53
+ # The requested pull request, issue, branch, or commit does not exist.
54
+ class ProviderObjectNotFoundError < Error; end
55
+ end
56
+ end
@@ -91,7 +91,7 @@ module Ace
91
91
  elsif exclude_renames?
92
92
  flags << "--diff-filter=ACDMTUXB"
93
93
  elsif exclude_moves?
94
- # Moves appear as rename with 100% similarity, disable high-similarity renames
94
+ # Moves appear as renames with total similarity; disable such exact renames
95
95
  flags << "-M0" # Disable rename detection (moves are renames at 100% similarity)
96
96
  end
97
97
  flags
@@ -164,7 +164,7 @@ module Ace
164
164
  hash.each_key do |key|
165
165
  key_str = key.to_s
166
166
  next if KNOWN_KEYS.include?(key_str)
167
- # Skip nested sections that may be passed through
167
+ # Skip nested sections that may be propagated
168
168
  next if %w[diff rebase pr squash default_branch remote verbose].include?(key_str)
169
169
 
170
170
  warn "[ace-git] Unknown config key '#{key_str}' in DiffConfig - did you mean one of: #{KNOWN_KEYS.join(", ")}?"
@@ -68,7 +68,7 @@ module Ace
68
68
  # Check if has associated PR
69
69
  # @return [Boolean] True if PR metadata present
70
70
  def has_pr?
71
- !pr_metadata.nil? && !pr_metadata.empty?
71
+ !pr_metadata.nil?
72
72
  end
73
73
 
74
74
  # Check if has detected task pattern
@@ -35,17 +35,14 @@ module Ace
35
35
  # @param executor [Module] Command executor
36
36
  # @return [Hash] { ahead: Integer, behind: Integer }
37
37
  def tracking_status(executor: Atoms::CommandExecutor)
38
- result = executor.execute("git", "rev-list", "--left-right", "--count", "@{upstream}...HEAD")
38
+ ahead = count_commits("@{upstream}..HEAD", executor: executor)
39
+ behind = count_commits("HEAD..@{upstream}", executor: executor)
39
40
 
40
- unless result[:success]
41
+ if ahead.nil? || behind.nil?
41
42
  return {ahead: 0, behind: 0, error: "No tracking branch or not in git repo"}
42
43
  end
43
44
 
44
- parts = result[:output].strip.split(/\s+/)
45
- {
46
- ahead: parts[1].to_i,
47
- behind: parts[0].to_i
48
- }
45
+ {ahead: ahead, behind: behind}
49
46
  end
50
47
 
51
48
  # Get full branch information
@@ -85,6 +82,15 @@ module Ace
85
82
  "#{behind} behind"
86
83
  end
87
84
  end
85
+
86
+ # Count commits in a rev-list range; nil when the range is invalid
87
+ # (e.g. no upstream configured)
88
+ def count_commits(range, executor: Atoms::CommandExecutor)
89
+ result = executor.execute("git", "rev-list", "--count", range)
90
+ return nil unless result[:success]
91
+
92
+ result[:output].strip.to_i
93
+ end
88
94
  end
89
95
  end
90
96
  end
@@ -13,7 +13,7 @@ module Ace
13
13
  class << self
14
14
  # Load configuration using ace-config cascade with deep merge
15
15
  # Priority: instance_config merged over global config (which is already defaults + user)
16
- # @param instance_config [Hash] Instance-level configuration (highest priority)
16
+ # @param instance_config [Hash] Instance-level configuration (top priority)
17
17
  # @return [Models::DiffConfig] Merged configuration
18
18
  def load(instance_config = {})
19
19
  # Get global config from ace-git (already merged defaults + user per ADR-022)