Facade Implementation — Reference
Detailed reference for Git::Repository::* facade modules and methods. This file
is loaded by subagents during the Facade Implementation workflow.
Contents
- Contents
- Files to generate
- Topic module selection
- Designing a facade method
- Topic module skeleton
- The five facade responsibilities checklist
- Argument pre-processing patterns
- Internal helpers and encapsulation
- Parser vs. raw stdout
- Result-class factory methods
-
Common failures
- One-line delegation when orchestration is needed
- Leaking command-class types into the public API
- Exposing command-DSL-shaped argv in the facade signature
- Changing the legacy return type or signature on extraction
- Bypassing
@execution_context - Placing an overridable policy default after caller options
- Skipping option whitelisting on opaque opts hashes
- Mixing facade and command responsibilities
- Adding a topic module whose methods fit an existing one
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 testsspec/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— addrequire 'git/repository/<topic>'andinclude 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:
- 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).
- 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:
- 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, checkGit::Repository::Branchingfor existing siblings). - If those siblings form a coherent topic that does not fit any existing module, create the new module so subsequent additions have a home.
- 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 +
Operationswhen 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 (
Branching→branching.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
*Infoor*Resultsuffix — 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:
- Changing an existing
Git::Repositorymethod? Preserve the existing return type exactly — same shape, same nil/empty semantics. Backward compatibility for callers ofGit::Repositoryis the public contract; capture the existing return in the Step 2 plan. -
No legacy method (greenfield facade method)? Choose the return type from
the public-API perspective, in this order of preference:
- A domain object (
Git::BranchInfo,Git::DiffResult, …) when the output has structure callers will inspect. - A primitive (
Stringof chomped stdout,Boolean,Integer) when the output is a single value. nilorselfwhen the method is called for its side effects.Git::CommandLineResultonly when the topic module explicitly documents that as its contract (rare — reserved for low-level escape hatches). Do not returnCommandLineResultby default just because the command returns it.
- A domain object (
- 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:
- Changing an existing
Git::Repositorymethod? Preserve the existing signature exactly: same positional arguments in the same order, same defaults, sameopts = {}vs.**optionsshape, same nil/sentinel semantics. Capture the existing signature in the Step 2 plan and diff against it after implementation. -
No legacy method (greenfield facade method)? Design the signature from
the public-API perspective:
- Positional arguments for the natural domain identifiers the method operates on (paths, refs, names, messages). At most two or three.
- Keyword arguments with
**optionsfor option hashes. Prefer**optionsoveropts = {}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'sStyle/ArgumentsForwardingcop 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). - 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**optionsonce the set exceeds ~3 keys.
- Validate cross-argument constraints in the facade, before calling the
command. Raise
ArgumentErrorwith a message that names the offending arguments — for example,pullraises whenbranchis given withoutremote. The command class stays neutral about Ruby-level argument relationships. - Never expose command-DSL-shaped arguments (
*argv, rawHashof 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
#callsignature (with at most trivial coercion likeArray(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(, opts = {})
SharedPrivate.assert_valid_opts!(COMMIT_ALLOWED_OPTS, **opts)
opts = opts.merge(message: ) if
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:
- Pre-process — validate, whitelist, normalize, deprecate, default.
- Call — invoke one or more
Git::Commands::*instances, each via@execution_context. - Assemble — pass stdout/stderr/status through a parser or result-class
factory method, or return the raw
CommandLineResultvalue 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 rawCommandLineResultis 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_OPTSand mark itprivate_constant. It is implementation detail, not part of the public API. - Place the constant immediately before the method definition.
- The whitelist must match the
@optiontags in the YARD doc exactly. Reviewers should verify the two lists are equal in both directions. SharedPrivate.assert_valid_opts!raisesArgumentError: 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 defensivesliceat the call site — is what guarantees the whitelist stays load-bearing under future refactors. Forward**optionsdirectly after the assertion; do not alsosliceit (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(, opts = {})
opts = opts.merge(message: ) if
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:
- Re-creates the god-class problem
Git::Libhad — the reason the redesign introduced topic modules. - Couples every topic module silently to ambient mixin state.
- 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, **)
unknown = .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::Repositoryinstances do not gainassert_valid_opts!as a method. The helper is namespaced and private-by-API. - Explicit dependency. A reader of
staging.rbsees exactly where the helper comes from. No magic mixin chain. - Stateless by contract. Without
include, helpers cannot access@execution_contextor other instance state — they must take everything as arguments. This keeps them pure and trivially unit-testable. - Truly private constant.
private_constant :SharedPrivatecauses fully-qualified external references (Git::Repository::SharedPrivate) to raise aNameErrorat runtime. Callers inside theGit::Repositoryclass body (i.e. the topic modules) use the shortSharedPrivate.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@apitag and absence of anincludeline inlib/git/repository.rbalready convey API status. A*Helperssuffix 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:
SharedPrivatewould exceed ~5 methods after the addition.- 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:
- Is it used by only one topic module? →
module Privatenested inside that topic module. - Is it used by two or more modules, and does an existing
SharedPrivate::*submodule cover this concern? → Add the method to that submodule. - 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, )
# ...
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:
-
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 -
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 notincludeit. Inlib/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.