Proposed Redesigned Architecture for the Git Gem
April 27, 2026 · View on GitHub
This document outlines a proposal for a major redesign of the git gem, targeted for version 5.0.0. The goal of this redesign is to modernize the gem's architecture, making it more robust, maintainable, testable, and easier for new contributors to understand.
- 1. Motivation
- 2. The New Architecture: A Three-Layered Approach
- 3. Key Design Principles
- 4. Testing Strategy Overhaul
- 5. Impact on Users: Breaking Changes for v5.0.0
1. Motivation
The current architecture, while functional, has several design issues that have accrued over time, making it difficult to extend and maintain.
-
Unclear Separation of Concerns: The responsibilities of the
Git,Git::Base, andGit::Libclasses are "muddy."Git::Baseacts as both a high-level API and a factory, whileGit::Libcontains a mix of low-level command execution and high-level output parsing. -
Circular Dependency: A key architectural flaw is the circular dependency between
Git::BaseandGit::Lib.Git::Basecreates and depends onGit::Lib, butGit::Lib's constructor requires an instance of Git::Base to access configuration. This tight coupling makes the classes difficult to reason about and test in isolation. -
Undefined Public API: The boundary between the gem's public API and its internal implementation is not clearly defined. This has led some users to rely on internal classes like
Git::Lib, making it difficult to refactor the internals without introducing breaking changes. -
Slow and Brittle Test Suite: The current tests rely heavily on filesystem fixtures and shelling out to the git command line for almost every test case. This makes the test suite slow and difficult to maintain, especially on non-UNIX platforms.
2. The New Architecture: A Three-Layered Approach
The new design is built on a clear separation of concerns, dividing responsibilities into three distinct layers: a Facade, an Execution Context, and Command Objects.
-
The Facade Layer: Git::Repository
This is the primary public interface that users will interact with.
Renaming:
Git::Basewill be renamed toGit::Repository. This name is more descriptive and intuitive.Responsibility: The facade layer is responsible for:
-
Managing the Execution Context: Holding and providing access to the configured execution context for command execution.
-
Pre-processing Arguments: Transforming user-provided arguments to fit the command API (e.g., path expansion, Ruby-idiomatic defaults).
-
Collecting Data: Gathering additional information before or after command execution that may be needed for building response objects.
-
Calling Commands: Invoking one or more
Git::Commands::*classes as needed to fulfill the user's request. -
Building Rich Response Objects: Using Parser classes (e.g.,
Git::Parsers::Diff) and Result class factory methods (e.g.,BranchDeleteResult.from(...)) to assemble meaningful return values from raw command output.
Facade as Orchestration Layer: The facade layer acts as glue and orchestration code. It coordinates the flow between components but contains minimal domain logic itself. The actual implementation work is delegated to specialized components:
- Commands → handle argument building and execution, return
CommandLineResult - Parsers → transform stdout/stderr into structured data
- Result classes → assemble final objects via factory methods
This separation means the facade's role is to wire things together (which component to call, in what order, with what inputs) rather than implement behavior (how to parse output, which arguments are valid). This keeps each component focused, independently testable, and reusable.
Scalability: To prevent this class from growing too large, its methods will be organized into logical modules (e.g.,
Git::Repository::Branching,Git::Repository::History) which are then included in the main class. This keeps the core class definition small and the features well-organized. These categories will be inspired by (but not slavishly follow) the git command line reference in this page. -
-
The Execution Layer: Git::ExecutionContext
This is the low-level, private engine for running commands.
Renaming:
Git::Libwill be renamed toGit::ExecutionContext.Responsibility: Its purpose is to provide a configured
commandmethod for executing git commands. This method wrapsGit::CommandLinewith essential functionality including default options (normalize, chomp, timeout), option validation, and a simplified interface that returns stdout. The execution context has no knowledge of any specific git command's arguments or output.Two Context Types: The execution layer will consist of an abstract base class with two concrete implementations:
-
Git::ExecutionContext::Global: For commands that do not require an existing repository (
init,clone,config --global,version). These commands execute in a clean environment with no repository paths set. In the specific case ofinit/clone, the command itself runs inExecutionContext::Global, but on success it yields a newly createdGit::Repositoryinstance backed by aGit::ExecutionContext::Repository. -
Git::ExecutionContext::Repository: For repository-bound commands (
add,commit,status,log, etc.). Manages the repository environment (working directory, .git path, index file) and provides the ability to override environment variables per-command (e.g., unsettingGIT_INDEX_FILEfor worktree mutations).
The base
ExecutionContextclass provides the commoncommandmethod that wraps command execution with defaults, validation, and timeout handling. Subclasses implement environment-specific configuration (paths, environment variables) to create properly configured command execution contexts. -
-
The Command Layer: Git::Commands
This layer provides a structured interface to individual git commands.
New Classes: For each git operation, a new command class will be created within the
Git::Commandsnamespace (e.g.,Git::Commands::Commit,Git::Commands::Diff).Command Responsibilities: Each command class is responsible for:
-
Defining the Git CLI API: Using
Arguments.defineto declaratively specify the command's accepted arguments. Command parameters should generally match the underlying git command's interface to keep this layer thin and transparent. -
Binding Arguments: Exposing bound arguments publicly via the
#bindmethod, allowing the facade layer to access argument values when needed for orchestration or result building. -
Executing the Command: Running the git command via the execution context with any special setup/tweaking for expected results (e.g.,
raise_on_failure: falsefor partial failures), returning aGit::CommandLineResult.
Note: Parsing output and building rich response objects is not a command responsibility. That work belongs in separate Parser classes (e.g.,
Git::Parsers::Diff) and Result class factory methods, orchestrated by the Facade layer. See issue #997 for the work to migrate existing commands that currently parse output.Commands::Base Pattern: All command classes inherit from
Git::Commands::Base. Implemented in issue #996.Simple commands declare
arguments do … endand provide a YARD shim:class Add < Base arguments do literal 'add' flag_option :all flag_option :force operand :paths, repeatable: true, default: [], separator: '--' end # Execute the git add command # ...YARD docs... def call(...) = super # rubocop:disable Lint/UselessMethodDefinition endCommands with non-zero successful exits declare their accepted range:
class Diff::Patch < Base arguments do literal 'diff' literal '--patch' # ... end # git diff exits 1 when differences are found (not an error) allow_exit_status 0..1 def call(...) = super # rubocop:disable Lint/UselessMethodDefinition endThis pattern provides:
- Declarative argument definition via the class-level
argumentsDSL - Behavioral inheritance from
Base(#initializeand#call) - Unified exit-status handling via
allow_exit_status <Range>(default0..0) - Per-command YARD documentation via
def call(...) = supershim - Automatic execution option forwarding (e.g.,
timeout:) viaBound#execution_options
Method Return Values:
#call→ ReturnsGit::CommandLineResult(stdout, stderr, status)#args_definition→ Returns the frozenArgumentsinstance (class-level metadata)
No Bind/Call Split: Arguments are bound as a local variable inside
Base#call. Commands remain stateless beyond@execution_context— there is no separate#bindmethod. If facade-layer access to bound argument values is needed in the future, a#bindmethod can be added as a backward-compatible addition. See the design rationale in issue #996.Migration Strategy: Git::Lib as Adapter Layer
During the migration to this architecture,
Git::Libmethods serve as adapters between the legacy public interface and the newGit::Commands::*classes. This separation ensures backward compatibility while enabling incremental migration:-
Legacy Interface Acceptance:
Git::Libmethods continue to accept the historical interface—positional arguments, deprecated options, and quirky parameter names. -
Interface Translation: The adapter converts legacy patterns to the clean
Git::Commands::*API (e.g., positionalmessage→:messagekeyword,:no_gpg_sign => true→:gpg_sign => false). -
Deprecation Handling: Warnings about deprecated options are issued in the adapter layer before delegating, ensuring users are informed even if they make other errors.
-
Clean Command Classes:
Git::Commands::*classes remain free of legacy baggage with a consistent, modern API. -
Result Building: The adapter layer is responsible for transforming
CommandLineResultinto rich response objects using Parser classes and Result factories. Commands return raw results; adapters build domain objects.
Once all commands are migrated and deprecation periods end, the adapter logic can be simplified or moved to the new facade layer (
Git::Repository).Arguments DSL: To standardize argument building across commands, a declarative
Git::Commands::ArgumentsDSL is provided. This allows each command to define its accepted arguments in a clear, self-documenting way:ARGS = Git::Commands::Arguments.define do flag_option :force # --force when true flag_option :all # --all when true value_option :branch # --branch <value> value_option :config, repeatable: true # --config <v1> --config <v2> flag_option :single_branch, negatable: true # --single-branch / --no-single-branch custom_option(:depth) { |v| ['--depth', v.to_i] } operand :paths, repeatable: true, separator: '--' endThe DSL supports several option types (
flag_option,value_option,flag_or_value_option,literal,custom_option,execution_option) and positional arguments, each with various modifiers. See Git::Commands::Arguments for full documentation.Interface Convention: With the
Basepattern, all commands usedef call(...) = superas a YARD documentation shim.Base#callhandles argument binding and execution automatically; defaults defined in the DSL (e.g.,operand :paths, default: []) are applied during binding, so no manual default checking is needed:# All commands: YARD shim delegates to Base#call def call(...) = super # rubocop:disable Lint/UselessMethodDefinitionThe facade layer (
Git::Base,Git::Lib) handles translation from the public API (which may accept single values or arrays) using*Array(paths).Return Value Convention: The
#callmethod returnsGit::CommandLineResultby default. This is the standard return type for commands, containing stdout, stderr, and status information.Important: Rich objects (
StashInfo,BranchInfo,BranchDeleteResult, etc.) are built by the Facade layer (currentlyGit::Lib, eventuallyGit::Repository), not by commands. The facade layer uses:- Parser classes (e.g.,
Git::Parsers::Diff,Git::Parsers::Stash) to transform raw stdout/stderr into structured data - Result class factory methods (e.g.,
BranchDeleteResult.from(...)) to assemble final objects from parsed data
This separation keeps commands focused on execution and the facade responsible for building meaningful return values:
# Command layer: returns CommandLineResult class Git::Commands::Stash::List < Base arguments do literal 'stash' literal 'list' # ... end def call(...) = super # rubocop:disable Lint/UselessMethodDefinition end # Facade layer: builds rich objects def stashes result = Git::Commands::Stash::List.new(self).call StashListParser.parse(result.stdout).map { |info| Stash.new(self, info) } endNaming Convention for Return Types: Use the
-Infosuffix for data objects representing git entities (e.g.,TagInfo,BranchInfo,StashInfo), and useResultfor operation outcomes (e.g.,FsckResult,TagDeleteResult).Handling Complexity: For commands with multiple behaviors (like git diff), we can use specialized subclasses (e.g., Git::Commands::Diff::NameStatus, Git::Commands::Diff::Stats) to keep each class focused on a single responsibility.
-
3. Key Design Principles
The new architecture will be guided by the following modern design principles.
A. Clear Public vs. Private API
A primary goal of this redesign is to establish a crisp boundary between the public API and internal implementation details.
-
Public Interface: The public API will consist of the
Gitmodule (for factories), theGit::Repositoryclass, and the specialized data/query objects it returns (e.g.,Git::Log,Git::Status,Git::Object::Commit). -
Private Implementation: All other components, including
Git::ExecutionContextand all classes within theGit::Commandsnamespace, will be considered internal. They will be explicitly marked with the@api privateYARD tag to discourage external use.
B. Dependency Injection
The circular dependency will be resolved by implementing a clear, one-way dependency flow.
-
The factory methods (
Git.open,Git.clone) will create and configure an instance of the appropriateGit::ExecutionContextsubclass (Git::ExecutionContext::Globalforinit/clone,Git::ExecutionContext::Repositoryforopen/bare). -
This context instance will then be wired into the system in two ways:
- For commands that run before a repository exists (e.g.,
Git::Commands::Init,Git::Commands::Clone), the context will be passed directly into the constructor of the command object. - For repository-scoped commands (e.g.,
Git::Commands::Log,Git::Commands::Status), the context will be injected once into theGit::Repositoryconstructor (foropen/bare), and those command objects will access the context through the repository instance rather than receiving it directly.
- For commands that run before a repository exists (e.g.,
This decouples the Repository from its execution environment, making the system
more modular and easier to test.
C. Immutable Return Values
To create a more predictable and robust API, methods will return structured, immutable data objects instead of raw strings or hashes.
This will be implemented using Data.define or simple, frozen Structs.
For example, instead of returning a raw string, repo.config('user.name') will
return a Git::Config::Value object containing the key, value, scope, and source
path.
Value Objects vs Domain Objects: A critical architectural distinction exists between:
-
Value objects (e.g.,
Git::BranchInfo): Pure data returned by commands. No repository context, no operations, no dependencies. Created byData.define. These are the return types ofGit::Commands::*classes. -
Domain objects (e.g.,
Git::Branch): Rich objects with operations that require repository context (@base). These wrap value objects and provide methods likecheckout,merge,delete.
Commands return value objects. The facade layer (Git::Repository) or collection
classes (Git::Branches) convert them to domain objects when needed. This separation
keeps commands pure and testable while domain objects provide the rich API users
expect.
Note on Data.define constraints: Objects created with Data.define are frozen.
This means memoization patterns like @cached ||= ... will raise FrozenError.
Either accept repeated computation or use a different approach for caching.
D. Eliminate Custom Path Classes
The existing path wrapper classes (Git::WorkingDirectory, Git::Index,
Git::Repository, and their base class Git::Path) provide minimal value over
Ruby's standard library. These classes will be eliminated entirely.
Git::Path-> RemovedGit::WorkingDirectory-> RemovedGit::Index-> RemovedGit::Repository(the path class) -> Removed
Instead, the dir, repo, and index accessors on the repository object will
return Pathname objects directly. This provides:
- Built-in
readable?andwritable?methods (preserving existing API) - Automatic path expansion and normalization
- Seamless string coercion via
to_sandto_path - No custom classes to maintain
Breaking change: Code using .path (e.g., g.dir.path) must change to .to_s
or use the Pathname directly. String interpolation and most other uses will
continue to work unchanged.
4. Testing Strategy Overhaul
The test suite will be modernized to be faster, more reliable, and easier to work with.
-
Migration to RSpec: The entire test suite will be migrated from TestUnit to RSpec to leverage its modern tooling and expressive DSL.
-
Layered Testing: A three-layered testing strategy will be adopted:
-
Unit Tests: The majority of tests will be fast, isolated unit tests for the
Commandclasses, using mockExecutionContexts. -
Integration Tests: A small number of integration tests will verify that
ExecutionContextcorrectly interacts with the system'sgitbinary. -
Feature Tests: A minimal set of high-level tests will ensure the public facade on
Git::Repositoryworks end-to-end.
-
-
Reduced Filesystem Dependency: This new structure will dramatically reduce the suite's reliance on slow and brittle filesystem fixtures.
5. Impact on Users: Breaking Changes for v5.0.0
This redesign is a significant undertaking and will be released as version 5.0.0. It includes several breaking changes that users will need to be aware of when upgrading.
-
Git::Libis Removed: Any code directly referencingGit::Libwill break. -
g.lib Accessor is Removed: The
.libaccessor on repository objects will be removed. -
Internal Methods Relocated: Methods that were previously accessible via g.lib will now be private implementation details of the new command classes and will not be directly reachable.
Users should only rely on the newly defined public interface.