Facade Implementation — Reference

Detailed reference for Git::Repository::* facade modules and methods. This file is loaded by subagents during the Facade Implementation workflow.

Contents

Files to generate

For a facade method on Git::Repository::<Topic>:

  • lib/git/repository/<topic>.rb — the topic module (created on first method, extended for subsequent methods)
  • spec/unit/git/repository/<topic>_spec.rb — unit tests
  • spec/integration/git/repository/<topic>_spec.rb — integration tests (omit for true one-line delegators that add no orchestration)

When the topic module is new, also update:

  • lib/git/repository.rb — add require 'git/repository/<topic>' and include Git::Repository::<Topic> in alphabetical order with the existing entries.

Topic module selection

Existing modules

List lib/git/repository/ to see all current topic modules. Add to one of those modules whenever the new method fits the topic. Do not create a new module when an existing one would do.

Decision rules for adding a new module

Create a new topic module only when both of the following are true:

  1. The topic is recognizable to a reader familiar with git — preferably matching one of the categories at https://git-scm.com/docs (Working tree, Branching, History, Sharing, Patching, Inspection, Configuration, Plumbing).
  2. The methods would be awkward to place in any existing module without diluting that module's topic.

There is no fixed method-count requirement. A module that starts with one or two methods is fine when the topic is genuinely distinct; the question is always fit, not count. Default to extending an existing module whenever the new method plausibly fits there.

Choosing a module for a new facade method

When placing a single facade method without a planned batch:

  1. Before deciding placement, scan existing Git::Repository::* modules for sibling methods on the same git topic (e.g. when adding a method related to branches, check Git::Repository::Branching for existing siblings).
  2. If those siblings form a coherent topic that does not fit any existing module, create the new module so subsequent additions have a home.
  3. Otherwise place the method in the closest existing module. Revisit module organization later if a distinct topic emerges; promote the cluster to its own module in a single refactor(repository): commit at that point.

For a one-off method that does not fit any existing module and does not justify a new module yet, place it in the closest existing module and revisit the organization when more methods join it.

Naming a new topic module

Topic modules follow a two-tier convention (documented in redesign/3_architecture_implementation.md §Facade module naming convention):

  • Gerund (verb-ing) when a single action word clearly names the whole module: Staging, Committing, Branching, Merging, Logging, Diffing, Stashing.
  • Noun + Operations when the module groups a mixed bag of methods by git concept rather than a single action: RemoteOperations, ObjectOperations, StatusOperations, WorktreeOperations.

Additional rules:

  • Use PascalCase; the file name is the snake_case equivalent (Branchingbranching.rb).
  • Do not use plain nouns that clash with existing public or domain-object class names such as Branch, Diff, Log, Object, Remote, Status, Worktree, etc. Those names belong to existing domain/query/value objects, not topic modules.
  • Do not use the *Info or *Result suffix — reserved for parsed result structs.

Designing a facade method

Decide the return type and the signature first (together they are the public contract), then choose the body shape — one-line delegator for the simplest cases, orchestration sequence otherwise.

Choosing the return type

Apply these rules in order:

  1. Changing an existing Git::Repository method? Preserve the existing return type exactly — same shape, same nil/empty semantics. Backward compatibility for callers of Git::Repository is the public contract; capture the existing return in the Step 2 plan.
  2. No legacy method (greenfield facade method)? Choose the return type from the public-API perspective, in this order of preference:
    1. A domain object (Git::BranchInfo, Git::DiffResult, …) when the output has structure callers will inspect.
    2. A primitive (String of chomped stdout, Boolean, Integer) when the output is a single value.
    3. nil or self when the method is called for its side effects.
    4. Git::CommandLineResult only when the topic module explicitly documents that as its contract (rare — reserved for low-level escape hatches). Do not return CommandLineResult by default just because the command returns it.
  3. Never return a type from Git::Commands::* (e.g. Git::Commands::Foo::Bar::SomeResult). Command-internal types are not part of the facade's public API.

Choosing the method signature

The Ruby signature is part of the public contract — just as binding as the return type. Apply these rules in order:

  1. Changing an existing Git::Repository method? Preserve the existing signature exactly: same positional arguments in the same order, same defaults, same opts = {} vs. **options shape, same nil/sentinel semantics. Capture the existing signature in the Step 2 plan and diff against it after implementation.
  2. No legacy method (greenfield facade method)? Design the signature from the public-API perspective:
    1. Positional arguments for the natural domain identifiers the method operates on (paths, refs, names, messages). At most two or three.
    2. Keyword arguments with **options for option hashes. Prefer **options over opts = {} in greenfield code — it surfaces unknown keys at the call site and reads better with the whitelist pattern. When the body forwards the options unchanged (the common case), use the anonymous keyword splat (**) so RuboCop's Style/ArgumentsForwarding cop is satisfied; name the splat (**options) only when the body must inspect or mutate the hash before forwarding (e.g. merging a positional argument into it, or applying a deprecation rewrite).
    3. Named keyword arguments (force: false, all: true) for a small, fixed set of flags that are part of the documented API and unlikely to grow. Switch to **options once the set exceeds ~3 keys.
  3. Validate cross-argument constraints in the facade, before calling the command. Raise ArgumentError with a message that names the offending arguments — for example, pull raises when branch is given without remote. The command class stays neutral about Ruby-level argument relationships.
  4. Never expose command-DSL-shaped arguments (*argv, raw Hash of CLI flags) on the facade. The facade's job is to translate Ruby idioms into command calls; passing through opaque argv defeats the layer.

Mechanical patterns for shaping the inputs (path coercion, option whitelisting, deprecation handling) live in Argument pre-processing patterns.

One-line delegator

When the facade method takes no options hash, does no pre-processing, and only a trivial post-processing step (such as .stdout.chomp), it is a single-line delegation. For example, a hypothetical Git::Repository::Inspection#current_branch preserving the String return type contract:

# Return the name of the currently checked-out branch
#
# @example Get the current branch name
#   repo.current_branch #=> "main"
#
# @return [String] the current branch name
#
# @raise [Git::FailedError] when `git rev-parse` exits with a non-zero status
#
def current_branch
  Git::Commands::RevParse.new(@execution_context).call('--abbrev-ref', 'HEAD').stdout.chomp
end

Use the one-line form only when all of the following hold:

  • The Ruby signature exactly matches the command's #call signature (with at most trivial coercion like Array(paths) or *[remote, branch].compact).
  • The method takes no options hash. Any facade method that accepts options must whitelist them — see Option whitelisting — which makes it at minimum a two-line orchestration.
  • The post-processing is at most a single chained call (.stdout, .stdout.chomp, etc.) that produces the documented return type (see Choosing the return type above).

If the documented return type requires parsing, multiple commands, validation, deprecation, option whitelisting, or any conditional logic, the facade needs an orchestration sequence — not a one-line delegator.

Orchestration sequence

When the facade method needs pre-processing, multiple commands, parsing, or result assembly, expand the body into explicit phases:

def branches_all
  result = Git::Commands::Branch::List.new(@execution_context).call(
    all: true,
    format: Git::Parsers::Branch::FORMAT_STRING
  )
  Git::Parsers::Branch.parse_list(result.stdout)
end

def commit(message, opts = {})
  SharedPrivate.assert_valid_opts!(COMMIT_ALLOWED_OPTS, **opts)
  opts = opts.merge(message: message) if message
  opts = deprecate_commit_no_gpg_sign_option(opts)
  Git::Commands::Commit.new(@execution_context).call(no_edit: true, **opts).stdout
end

Three phases — keep them in this order:

  1. Pre-process — validate, whitelist, normalize, deprecate, default.
  2. Call — invoke one or more Git::Commands::* instances, each via @execution_context.
  3. Assemble — pass stdout/stderr/status through a parser or result-class factory method, or return the raw CommandLineResult value the topic module documents.

Sequencing multiple commands

When a facade method orchestrates more than one command, sequence the calls explicitly with intermediate results in local variables:

def branch_status(name)
  upstream_result = Git::Commands::RevParse.new(@execution_context).call("#{name}@{upstream}")
  ahead_behind = Git::Commands::RevList.new(@execution_context).call(
    "#{name}...#{upstream_result.stdout.chomp}",
    left_right: true,
    count: true
  )
  Git::BranchStatus.from_rev_list_output(name, ahead_behind.stdout)
end

Do not build a generic dispatcher or a "run everything in parallel" abstraction. Explicit sequential calls are the documented pattern.

Topic module skeleton

The full file layout for a topic module under lib/git/repository/:

# frozen_string_literal: true

require 'git/commands/<command_a>'
require 'git/commands/<command_b>'
# require 'git/parsers/<parser>' — when the module uses a parser

module Git
  class Repository
    # Short summary of the topic and the facade methods it provides
    #
    # Included by {Git::Repository}.
    #
    # @api public
    #
    module Topic
      # YARD docs per facade-yard-documentation skill
      def method_a(...)
        # body
      end

      # YARD docs per facade-yard-documentation skill
      def method_b(...)
        # body
      end
    end
  end
end

Then wire into lib/git/repository.rb:

require 'git/repository/topic'
# ...

class Repository
  include Git::Repository::Topic
  # ...
end

The five facade responsibilities checklist

From redesign/2_architecture_redesign.md §2.1. For each facade method, confirm whether each responsibility applies and is handled:

  • [ ] Manage execution context — calls Git::Commands::*.new(@execution_context), never builds CLI argv directly and never bypasses the execution context.
  • [ ] Pre-process arguments — applies path expansion, Ruby-idiomatic defaults, option whitelisting, deprecations.
  • [ ] Collect data — gathers any additional information needed before or after command execution to build the response (e.g., reading config, listing refs). Most facade methods do not need this; flag explicitly when present.
  • [ ] Call commands — invokes one or more Git::Commands::* classes; multiple calls are sequenced explicitly with intermediate results held in local variables.
  • [ ] Build rich response objects — passes stdout through a Git::Parsers::* class or a result-class factory method to produce the documented return type. Returning the raw CommandLineResult is acceptable only when that is the documented public contract for the topic module.

Argument pre-processing patterns

Path normalization

Accept String or Array<String> for path arguments and splat into the command:

def add(paths = '.', **)
  SharedPrivate.assert_valid_opts!(ADD_ALLOWED_OPTS, **)
  Git::Commands::Add.new(@execution_context).call(*Array(paths), **).stdout
end

For path arguments that must be absolute or relative to the worktree root, expand with File.expand_path against @execution_context.git_work_dir.

Option whitelisting (preventing API expansion)

When the facade method accepts an options hash (positional opts = {} or keyword **options) and forwards it to a command, the underlying command class typically exposes many more options than the public facade contract. Without filtering, callers could pass options that happen to match command DSL names but were never part of the facade's public API — silently expanding the contract.

Use a per-method whitelist constant + SharedPrivate.assert_valid_opts!:

PULL_ALLOWED_OPTS = %i[allow_unrelated_histories].freeze
private_constant :PULL_ALLOWED_OPTS

def pull(remote = nil, branch = nil, **)
  raise ArgumentError, 'You must specify a remote if a branch is specified' if remote.nil? && !branch.nil?

  SharedPrivate.assert_valid_opts!(PULL_ALLOWED_OPTS, **)
  positional_args = [remote, branch].compact
  Git::Commands::Pull.new(@execution_context)
                     .call(*positional_args, no_edit: true, **)
                     .stdout
end

The helper's signature is assert_valid_opts!(allowed, **opts) — the allowed set comes first as a positional argument so callers can re-forward the anonymous splat (**) into both the assertion and the command call. Name the splat (**options) only when the body must inspect or mutate the options hash before forwarding it (see the commit example above for that case).

Rules:

  • Name the constant <METHOD>_ALLOWED_OPTS and mark it private_constant. It is implementation detail, not part of the public API.
  • Place the constant immediately before the method definition.
  • The whitelist must match the @option tags in the YARD doc exactly. Reviewers should verify the two lists are equal in both directions.
  • SharedPrivate.assert_valid_opts! raises ArgumentError: Unknown options: <key> for any unrecognized key. Document this with @raise [ArgumentError] on the facade method.
  • Every facade method that accepts an options hash must have a unit test that passes an unknown key and expects ArgumentError. That test — not a defensive slice at the call site — is what guarantees the whitelist stays load-bearing under future refactors. Forward **options directly after the assertion; do not also slice it (the assertion already proves every key is allowed, and a second mechanism invites cargo-culting and confusion about which one enforces the contract).

Even when the facade uses **options keyword forwarding, whitelist explicitly. Relying on the command's own ArgumentError couples the facade contract to the command's argument DSL, which is exactly what this layer exists to prevent.

Deprecation handling

Handle deprecated option keys explicitly in the facade — never let deprecation shims leak into the command class. Pattern:

def commit(message, opts = {})
  opts = opts.merge(message: message) if message
  opts = deprecate_commit_no_gpg_sign_option(opts)
  opts = deprecate_commit_add_all_option(opts)
  Git::Commands::Commit.new(@execution_context).call(no_edit: true, **opts).stdout
end

private

def deprecate_commit_no_gpg_sign_option(opts)
  return opts unless opts.key?(:no_gpg_sign)

  Git::Deprecation.warn(
    "Git::Repository#commit's :no_gpg_sign option is deprecated. " \
    'Use gpg_sign: false instead.'
  )
  opts.dup.tap do |o|
    o[:gpg_sign] = false unless o.key?(:gpg_sign)
    o.delete(:no_gpg_sign)
  end
end

Defaults and policy options

The facade is where policy defaults are applied — options that support non-interactive execution, control output format for parsing, or set safe command-level defaults. The command class stays neutral; the facade makes the defaults explicit.

There are two categories of policy defaults:

Fixed policy defaults are set unconditionally and are NOT included in the method's ALLOWED_OPTS constant. assert_valid_opts! rejects any caller-supplied value for these keys before it reaches the command call, enforcing the policy. They are not part of the facade's public API and must not be documented as @option tags.

Policy option Why facade sets it
no_edit: true Subprocesses cannot launch $EDITOR
no_progress: true Progress output goes to stderr and pollutes parsing
no_color: true ANSI escapes interfere with parsers
format: Git::Parsers::Foo::FORMAT_STRING Facade wants a parseable format

Overridable policy defaults are included in ALLOWED_OPTS. The facade sets a sensible default but callers may override it. Place these before the caller's **opts in the command call so the caller's value wins on key collision, and document them as @option tags since they are part of the public API:

# :verbose is in ALLOWED_OPTS — caller can pass verbose: true to override
Git::Commands::Log.new(@execution_context).call(*args, verbose: false, **opts)

Internal helpers and encapsulation

Topic modules under lib/git/repository/ often share helper logic — option validation, path normalization, deprecation warnings, error wrapping. These helpers must be reachable from any topic module without leaking onto the public Git::Repository API surface.

The rule

Do not put shared helpers as private methods on Git::Repository (directly or via include). include copies private instance methods onto the host class, so any caller with a Git::Repository instance can repo.send(:helper, ...). This:

  1. Re-creates the god-class problem Git::Lib had — the reason the redesign introduced topic modules.
  2. Couples every topic module silently to ambient mixin state.
  3. Is not actually private and not @api-marked, so YARD/tooling cannot enforce it.

The pattern

Put shared helpers in a sibling internal module under lib/git/repository/ that is not included into Git::Repository. Use module_function so methods are called as singleton methods from the topic modules:

# lib/git/repository/shared_private.rb
module Git
  class Repository
    # Namespace for internal helpers shared across facade topic modules
    #
    # @api private
    #
    module SharedPrivate
      module_function

      def assert_valid_opts!(allowed, **options)
        unknown = options.keys - allowed
        return if unknown.empty?

        raise ArgumentError, "Unknown options: #{unknown.join(', ')}"
      end
    end

    private_constant :SharedPrivate
  end
end

Call sites use the short unqualified form (since the constant is private, fully-qualified external references are not possible):

# lib/git/repository/staging.rb
def add(paths = '.', **)
  SharedPrivate.assert_valid_opts!(ADD_ALLOWED_OPTS, **)
  Git::Commands::Add.new(@execution_context)
                    .call(*Array(paths), **)
                    .stdout
end

Why this works

  • No mixin pollution. Git::Repository instances do not gain assert_valid_opts! as a method. The helper is namespaced and private-by-API.
  • Explicit dependency. A reader of staging.rb sees exactly where the helper comes from. No magic mixin chain.
  • Stateless by contract. Without include, helpers cannot access @execution_context or other instance state — they must take everything as arguments. This keeps them pure and trivially unit-testable.
  • Truly private constant. private_constant :SharedPrivate causes fully-qualified external references (Git::Repository::SharedPrivate) to raise a NameError at runtime. Callers inside the Git::Repository class body (i.e. the topic modules) use the short SharedPrivate.foo(...) form and are unaffected.

Naming rules

Topic modules (those included in Git::Repository) follow the two-tier naming convention in Naming a new topic module: gerund for single-action modules, Noun + Operations for mixed-bag modules.

Internal helper modules (those not included) use descriptive nouns, never generic role-suffixes like *Helpers, *Utils, or *Support. The *Operations suffix is a topic-module convention — it is not a generic role suffix and is not prohibited here:

Module Distinguished by
Git::Repository::Staging included, @api public (gerund topic module)
Git::Repository::Branching included, @api public (gerund topic module)
Git::Repository::RemoteOperations included, @api public (*Operations topic module)
Git::Repository::SharedPrivate not included, @api private, private_constant
Git::Repository::SharedPrivate::OptionValidation nested under SharedPrivate, @api private

Reasons:

  • The location (lib/git/repository/) plus the @api tag and absence of an include line in lib/git/repository.rb already convey API status. A *Helpers suffix is redundant signage.
  • Symmetry with topic modules keeps the directory listing readable.
  • "Helpers" invites junk-drawer dumping; responsibility-named modules invite cohesion. Ruby stdlib follows the same convention (URI::DEFAULT_PARSER, ActiveSupport::Inflector, not *Helpers).

Growth path

Every time a new method is added to SharedPrivate, count the total methods and look for sub-themes. If either trigger fires, extract before committing the new method:

  1. SharedPrivate would exceed ~5 methods after the addition.
  2. Clear sub-themes are visible (validation vs. normalization vs. error wrapping).

Then extract responsibility-named submodules nested under SharedPrivate:

Git::Repository::SharedPrivate                       # catch-all (initial)
        ↓ grows / develops sub-themes
Git::Repository::SharedPrivate::OptionValidation     # extracted by responsibility
Git::Repository::SharedPrivate::PathNormalization
Git::Repository::SharedPrivate                       # remaining miscellany (or deleted)

Submodules live in lib/git/repository/shared_private/option_validation.rb. They do not need their own private_constant since the parent is already private. The extraction is mechanical — call sites change from SharedPrivate.foo(...) to SharedPrivate::OptionValidation.foo(...).

Decision 1 — Where does the helper live?

Choose placement by working through these questions in order:

  1. Is it used by only one topic module?module Private nested inside that topic module.
  2. Is it used by two or more modules, and does an existing SharedPrivate::* submodule cover this concern? → Add the method to that submodule.
  3. Is it used by two or more modules, and no fitting submodule exists? → Add it directly to SharedPrivate, then apply the growth-path check (see Growth path) to decide whether a new submodule is now warranted.
Condition Placement
Used by one topic module only module Private nested inside that topic module
Shared; fitting SharedPrivate::* submodule exists That submodule (e.g. SharedPrivate::OptionValidation)
Shared; no fitting submodule exists SharedPrivate directly

Nested module Private — for helpers local to one topic module. Nest it inside the topic module, mark it @api private, and call its methods as Private.foo(...):

module Git
  class Repository
    module Branching
      # ... public facade methods ...

      # Helpers private to the `Branching` topic module
      #
      # @api private
      module Private
        module_function

        # Translates checkout options into git command arguments
        #
        # @param branch [String, nil] the target branch name
        #
        # @param options [Hash] caller-supplied checkout options
        #
        # @return [Array] a two-element tuple of translated arguments
        #
        # @api private
        def translate_checkout_opts(branch, options)
          # ...
        end
      end
      private_constant :Private
    end
  end
end

Existing SharedPrivate::* submodule — check lib/git/repository/shared_private/ for a file whose name matches the concern (e.g. option_validation.rb). If one exists, add the method there.

SharedPrivate directly — when no fitting submodule exists yet. See The pattern for the full skeleton. Call sites use SharedPrivate.foo(...). After adding, re-run the growth-path check.

Decision 2 — How is state passed to the helper?

module_function helpers (whether in module Private or SharedPrivate) are stateless by design — they cannot access @execution_context or any other instance state. This is intentional: stateless helpers are trivially unit-testable and have no hidden dependencies.

When a helper needs state, pass it explicitly rather than making the helper stateful:

  1. Pass state as an argument — the preferred approach. Add the execution context (or whatever state is needed) as a positional argument:

    module Private
      module_function
    
      def build_result(execution_context, name)
        Git::Commands::Foo.new(execution_context).call(name)
      end
    end
    
  2. Extract a PORO — when the helper has enough state and behavior to justify its own object. Place it under lib/git/repository/, mark it @api private, and do not include it. In lib/git/repository/commit_operation.rb:

    # Callable helper for executing a git commit
    #
    # @api private
    class Git::Repository::CommitOperation
      def initialize(execution_context)
        @execution_context = execution_context
      end
    
      def call(...)
        # ...
      end
    end
    

Avoid: inline private instance methods directly on the topic module (i.e., def after private in the module body without a Private namespace). This pollutes the Git::Repository instance namespace with methods reachable via repo.send(:helper, ...), which is the exact problem these patterns exist to prevent.

Why not ActiveSupport::Concern?

Concern does not solve the include-time leak: a Concern included into a class still copies its private instance methods onto that class. Rails tolerates the leak via underscore prefixes and :nodoc:, or extracts service objects. This project takes the stricter approach (sibling module + module_function) without depending on activesupport.

Parser vs. raw stdout

Situation Use
The facade returns a String of git's stdout (chomped or as-is) .stdout
The facade returns a structured object built from line-by-line parsing A Git::Parsers::* class
The facade returns a single bool/int derived from output Inline transformation in the facade
The facade returns a Git::CommandLineResult Return the raw result

If the parsing logic exceeds ~5 lines, extract it into a Git::Parsers::* class and call it from the facade. The facade method remains an orchestration sequence, not a parser.

Result-class factory methods

When the facade returns a domain object (e.g. BranchInfo, BranchDeleteResult, DiffResult), use a factory method on the result class rather than constructing it inline:

def branch_delete(name, force: false)
  result = Git::Commands::Branch::Delete.new(@execution_context).call(name, force: force)
  Git::BranchDeleteResult.from(name: name, command_result: result)
end

This keeps result-object construction in one place per type and makes parsers reusable across facade methods.

Common failures

One-line delegation when orchestration is needed

If the facade method discards information the caller documented as part of the return type (e.g. returns result.stdout when the caller expects a parsed Hash), the one-line form is wrong. Expand to the orchestration sequence and call the appropriate parser.

Leaking command-class types into the public API

The public return type should never be Git::Commands::Foo::Bar::SomeResult or any other type from Git::Commands::*. Returning Git::CommandLineResult is acceptable when the topic module documents that as its contract, but is not the default — see Choosing the return type. Returning domain objects (Git::BranchInfo, Git::DiffResult, etc.) is preferred for methods that produce structured data.

Exposing command-DSL-shaped argv in the facade signature

The facade signature is a Ruby API, not a transcription of the git CLI. Accepting a free-form *args that is forwarded straight to the command, or naming keyword arguments after CLI flags (e.g. no_ff:, set_upstream_to:) instead of Ruby-idiomatic names, leaks the command DSL into the public contract. Define the signature from the caller's perspective; translate to command DSL inside the body.

Changing the legacy return type or signature on extraction

When adding or changing a Git::Repository facade method, returning a different type, accepting a different positional/keyword shape, or changing nil-handling silently breaks every caller. Capture the existing contract in the Step 2 plan and diff before/after — see Choosing the return type rule 1 and Choosing the method signature rule 1.

Bypassing @execution_context

Constructing a command with anything other than @execution_context (e.g. Git::Commands::Add.new(self) from inside a facade method) is wrong. The facade holds a Git::ExecutionContext::Repository; commands must always be constructed with it.

Placing an overridable policy default after caller options

This anti-pattern applies to overridable policy defaults (those in ALLOWED_OPTS). Placing the default after **opts silently overwrites the caller's explicit value:

# ❌ Wrong — caller's :verbose is silently discarded
Git::Commands::Log.new(@execution_context).call(*args, **opts, verbose: false)

# ✅ Correct — caller's :verbose wins because opts is splatted last
Git::Commands::Log.new(@execution_context).call(*args, verbose: false, **opts)

Place overridable policy defaults before the caller's **opts so the caller's value wins on key collision. This does not apply to fixed policy options (not in ALLOWED_OPTS): assert_valid_opts! prevents those keys from reaching the command call at all.

Skipping option whitelisting on opaque opts hashes

If the facade accepts an options hash (positional opts = {} or keyword **options), it must call SharedPrivate.assert_valid_opts! against a private_constant-marked <METHOD>_ALLOWED_OPTS constant. Without it, callers can silently pass any key the command DSL happens to accept, which is API expansion that the facade did not commit to.

Mixing facade and command responsibilities

The facade does not build CLI argv. The command does not pre-process Ruby arguments or parse output. If a facade method calls command(...) directly (rather than Git::Commands::*.new(...).call(...)) it is bypassing the command layer; refactor by introducing or extending the appropriate command class first.

Adding a topic module whose methods fit an existing one

A topic module is not justified when its method(s) would fit naturally in an existing module. Before creating a new module, scan existing modules for a plausible home. The absence of a method-count threshold is not an invitation to fragment the API — place the method in the closest existing module unless the topic is genuinely distinct and the method would be awkward there. See Decision rules for adding a new module for the full two-criteria test.