Contributing to the git gem

Summary

Thank you for your interest in contributing to the ruby-git project.

This document provides guidelines for contributing to the ruby-git project. While these guidelines may not cover every situation, we encourage you to use your best judgment when contributing.

If you have suggestions for improving these guidelines, please propose changes via a pull request.

Please also review and adhere to our Code of Conduct when participating in the project. Governance and maintainer expectations are described in GOVERNANCE.md.

How to contribute

You can contribute in the following ways:

  1. Report an issue or request a feature
  2. Submit a code or documentation change

How to report an issue or request a feature

ruby-git utilizes GitHub Issues for issue tracking and feature requests.

To report an issue or request a feature, please create a ruby-git GitHub issue. Fill in the template as thoroughly as possible to describe the issue or feature request.

Local development setup

Before submitting a change, set up a working local development environment. bin/setup automates the bootstrap and fails fast with a clear message when a prerequisite is missing.

Prerequisites

Tool Required version Notes
Ruby >= 3.2.0 (matches required_ruby_version in git.gemspec) A version manager such as rbenv, asdf, chruby, or rvm is recommended so you can match the project's CI matrix.
Bundler Any 2.x or 4.x Install with gem install bundler.
git >= 2.28.0 (matches git.gemspec requirements) Older git versions are not supported and the test suite will not pass against them.
Node.js / npm Optional Required only to install the local Conventional Commit commit-msg hook (Husky + commitlint). If npm is missing, bin/setup will warn and continue — CI will still validate commit messages.

Bootstrap the project

From the project root, run:

bin/setup

bin/setup will:

  1. Verify the prerequisites above and exit with a non-zero status if any are missing or out of date.
  2. Run bundle install to install Ruby gem dependencies.
  3. Run npm install (when npm is available) to install the Conventional Commit commit-msg hook used by this project (Husky + commitlint). A separate pre-commit hook is also installed that blocks direct commits to the protected branches (main, 4.x).
  4. Verify the toolchain by running bundle exec rake --tasks.

Verify the toolchain

Once bin/setup succeeds, confirm the full test and lint suite passes locally:

bundle exec rake

This is the same default task that runs in CI and is the canonical way to validate a change before requesting review.

Contributor validation policy

Contributors are expected to run bundle exec rake locally and confirm it passes before requesting review on a pull request — trivial documentation-only fixes (e.g., typo corrections in markdown files) are excepted. "CI passed" is not a substitute for local validation; it is a backstop. This applies equally to human-authored and AI-assisted contributions — see AI-assisted contributions.

How to submit a code or documentation change

There is a three-step process for submitting code or documentation changes:

  1. Commit your changes to a fork of ruby-git using Conventional Commits
  2. Create a pull request
  3. Get your pull request reviewed

Commit your changes to a fork of ruby-git

Make your changes in a fork of the ruby-git repository.

Create a pull request

If you are not familiar with GitHub Pull Requests, please refer to this article.

Follow the instructions in the pull request template.

Get your pull request reviewed

Code review takes place in a GitHub pull request using the GitHub pull request review feature.

Once your pull request is ready for review, request a review from at least one maintainer and any other contributors you deem necessary.

During the review process, you may need to make additional commits, which should be squashed. Additionally, you will need to rebase your branch to the latest version of the target branch (e.g., main or 4.x) before merging.

At least one approval from a project maintainer is required before your pull request can be merged. The maintainer is responsible for ensuring that the pull request meets the project's coding standards.

Before requesting review

Before moving a pull request out of draft or requesting a review, confirm:

  • [ ] bundle exec rake passes locally on your branch (see Local development setup).
  • [ ] New or changed code has accompanying tests under spec/ (see Unit tests).
  • [ ] Every commit message follows Conventional Commits.
  • [ ] User-facing changes are documented in README.md and/or YARD as appropriate.

These checks mirror what reviewers and CI will look for; running them locally first keeps the review cycle short.

Branch strategy

This project maintains two active branches:

  • main: Active development for the next major version (v5.0.0+). This branch may contain breaking changes.
  • 4.x: Maintenance branch for the v4.x release series. This branch receives bug fixes and backward-compatible improvements only.

Important: Never commit directly to main or 4.x. All changes must be submitted via pull requests from feature branches. This ensures proper code review, CI validation, and maintains a clean commit history.

When submitting a pull request:

  • New features and breaking changes: Target the main branch
  • Bug fixes: Target main, and maintainers will backport to 4.x if applicable
  • Security fixes: Target both branches or 4.x if the issue only affects v4.x

AI-assisted contributions

AI-assisted contributions are welcome. Please review and apply our AI Policy before submitting changes. You are responsible for understanding and verifying any AI-assisted work included in PRs and ensuring it meets our standards for quality, security, and licensing.

The human submitter — not the AI agent — is responsible for ensuring that bundle exec rake passes locally before requesting review. This is true even when the change was authored end-to-end by an agent. "The agent ran the tests" and "CI is green" are not substitutes for the submitter running the local validation step themselves; CI is a backstop, not a primary validation surface.

Design philosophy

The git gem is designed as a lightweight wrapper around the git command-line tool, providing Ruby developers with a simple and intuitive interface for programmatically interacting with Git.

This gem adheres to the "principle of least surprise," ensuring that it does not introduce unnecessary abstraction layers or modify Git's core functionality. Instead, the gem maintains a close alignment with the existing git command-line interface, avoiding extensions or alterations that could lead to unexpected behaviors.

By following this philosophy, the git gem allows users to leverage their existing knowledge of Git while benefiting from the expressiveness and power of Ruby's syntax and paradigms.

Command-layer neutrality

Command classes (Git::Commands::*) are faithful, neutral representations of the git CLI. They declare every option via the DSL but never embed policy choices — output-control flags, editor suppression, progress output, verbose mode, etc. Policy decisions belong to the facade (Git::Lib), which sets safe defaults at each call site. Callers may override those defaults when they have a legitimate reason (e.g., running in a TTY-attached environment where an editor is desired).

This principle serves two purposes: it keeps the command layer a reusable, unbiased interface to git, and it supports this gem's non-interactive execution model (git is never allowed to prompt for input, open an editor, or wait for TTY interaction by default). The execution layer provides an unconditional safety net regardless of what the caller passes.

The three architectural layers each play a distinct role:

Layer Responsibility Mechanism
Command (Git::Commands::*) Neutral git CLI interface Declares options via DSL (e.g. flag_option :edit, negatable: true) — no policy
Facade (Git::Lib) Safe defaults Sets policy options at each call site (e.g. edit: false); callers may override
Execution (Git::CommandLine) Unconditional safety net GIT_EDITOR='true' in every subprocess environment

Anti-pattern: literal '--no-edit', literal '--verbose', literal '--no-progress' inside a command class — embeds policy in the wrong layer.

Correct pattern: flag_option :edit, negatable: true in the command; edit: false passed from the facade call site.

Wrapping a git command

Note: This documentation reflects Phase 2 (Strangler Fig) of the architectural redesign. It will be updated in Phase 3 when Git::Repository becomes the primary public API and Git::Lib is bypassed. Currently, Git::Base remains the public API and Git::Lib acts as the delegation layer.

This section guides you through wrapping a git command. The first subsections focus on API design: where methods belong, how to name them, and how to handle parameters and output. These describe the public interface that gem users will see.

From design to implementation then shows how to structure your code using the gem's three-layer architecture. Note that while we are transitioning to Git::Repository, the current public API is Git::Base, which delegates to Git::Lib, which in turn delegates to internal command classes.

Note: When adding new git command wrappers, always use the new architecture described in "From design to implementation" with Git::Commands::* classes and the Arguments DSL. The gem is being incrementally migrated from Git::Lib to this pattern. Do not add new methods directly to Git::Lib.

Method placement

When implementing a git command, first determine what type of command it is. This determines where to implement it in the Ruby API:

Note: These placement guidelines define the public API. Always add public methods to Git module or Git::Base (which acts as the current facade for Git::Repository), even though the implementation will be in a Git::Commands::* class.

Repository factory methods are implemented on the Git module. Use these to obtain a repository object for subsequent operations:

repo = Git.clone('https://github.com/user/repo.git', 'local_path')
repo = Git.init('new_repo')
repo = Git.open('.')

Repository-scoped commands operate within a repository context. Implement these Git::Base instance methods:

repo.add('file.txt')
repo.commit('Add file')
repo.log

Non-repository commands do not require a repository context. Implement these as methods on the Git module:

Git.config_get('user.name', global: true)
Git.config_set('user.email', 'user@example.com', global: true)

Some commands, like git config, can operate in multiple contexts:

  • On the Git module: A scope parameter (global: true, system: true) or file: parameter is required. The local: and worktree: options are not allowed since they require a repository.
  • On a Git::Base instance: The command defaults to the repository's local scope. The worktree: true option is also available.

Method naming

Each method corresponds directly to a git command. For example, the git add command is implemented as Git::Base#add, and the git ls-files command is implemented as Git::Base#ls_files.

When a single Git command serves multiple distinct purposes, method names should use the git command name as a prefix, followed by a descriptive suffix indicating the specific function. The suffix should correspond to the git option that distinguishes the behavior.

For example, git config supports --get, --set, --list, --unset, and other options. These are implemented as separate methods:

repo.config_get('user.name')              # git config --get user.name
repo.config_set('user.name', 'Scott')     # git config user.name Scott
repo.config_list                          # git config --list
repo.config_unset('user.name')            # git config --unset user.name
repo.config_get_all('remote.origin.url')  # git config --get-all remote.origin.url

To enhance usability, aliases may be introduced to provide more user-friendly method names where appropriate.

See also Output processing for when different output formats require separate methods.

Result class naming

Parsed result objects returned from facade methods follow a reserved suffix convention:

  • *Info — a parsed metadata struct returned from a query (e.g., BranchInfo, TagInfo, StashInfo, DiffInfo). Always lives in the top-level Git:: namespace.
  • *Result — the outcome of a mutating or destructive operation (e.g., BranchDeleteResult, TagDeleteResult). Also lives in Git::.

Do not use these suffixes on Git::Commands::* command classes — those are subprocess runners, not data objects. A reader seeing Commands::Foo::BarInfo expects a parsed struct, not a class that shells out to git.

Parameter naming

Parameters within the git gem methods are named after their corresponding long command-line options, ensuring familiarity and ease of use for developers already accustomed to Git.

For example, git config --global becomes global: true, and git config --file becomes file: '/path/to/config'.

As a lightweight wrapper, the gem passes options directly to the git command-line. This means git itself will validate option combinations and report errors. This approach is preferred as long as the error messages returned by git are actionable and understandable for users of the gem.

When multiple options are mutually exclusive (like --global, --local, --system), only one may be specified. Providing more than one will raise an ArgumentError.

Note that not all Git command options are supported.

Parameter values

This section defines how git command-line options and positional arguments map to Ruby method parameters. Contributors must follow these conventions:

Options

Git command-line options are passed as keyword arguments in the Ruby API. Methods accept these via an options splat parameter (e.g., def replace(object, replacement, **options)). Each option is mapped to a keyword argument as described below.

  • Boolean flags: Git options like --global or --bare are mapped to global: true or bare: true. Omit the key or use false to leave the flag unset.

    • git config --globalglobal: true
  • Negated boolean flags: Options like --no-reflogs are mapped to no_reflogs: true.

    • git branch --no-reflogsno_reflogs: true
  • Value options: Options that take a value, such as --file <path> or --author <name>, are mapped as file: '/path', author: 'Name'.

    • git config --file /tmp/configfile: '/tmp/config'
  • Options with optional values: If a git option can be used as a flag or with a value (e.g., --color or --color=always), use color: true for the flag form, or color: 'always' for the value form.

    • git log --colorcolor: true
    • git log --color=alwayscolor: 'always'
  • List/array options: Options that can be repeated or take multiple values (e.g., --exclude <pattern>, --pathspec-from-file <file>) are mapped to arrays: exclude: ['foo', 'bar'].

    • git ls-files --exclude=foo --exclude=barexclude: ['foo', 'bar']
  • Key-value pair options: Options like -c key=value are mapped as c: { 'key' => 'value' } or as an array of pairs if multiple are allowed.

    • git -c user.name=Scottc: { 'user.name' => 'Scott' }
  • Mutually exclusive options: If options are mutually exclusive (e.g., --global, --local, --system), only one may be used at a time. Setting more than one raises ArgumentError. The DSL enforces this via conflicts declarations at bind time. For negatable flag options (negatable: true), passing false (which emits --no-flag) also counts as using that option in the conflict check; non-negatable false is treated as absent.

  • Forbidden value combinations (negatable flags): When two negatable flags may both be present but only certain value pairings are contradictory, use forbid_values declarations instead of (or in addition to) conflicts. conflicts is presence-based and blocks all co-presence; forbid_values blocks only the exact name: value tuples listed, leaving semantically equivalent pairs valid. For example, --all --no-ignore-removal and --no-all --ignore-removal are equivalent and should remain allowed, while --all --ignore-removal and --no-all --no-ignore-removal are contradictory and should be rejected:

  forbid_values all: true,  ignore_removal: true   # contradictory
  forbid_values all: false, ignore_removal: false  # contradictory

Unknown names raise ArgumentError at definition time. Alias names are canonicalized automatically.

  • Exactly-one required from a mutually exclusive group: When exactly one of a group of arguments must be provided (e.g., a command that accepts exactly one of --mode-a, --mode-b, or --mode-c), omitting all of them or supplying more than one raises ArgumentError. The DSL enforces this via requires_exactly_one_of declarations, which combine requires_one_of (at-least-one) and conflicts (at-most-one) in a single declaration.

  • At-least-one required: When at least one of a group of arguments (options or positional) must be provided, but the group is not mutually exclusive, omitting all of them raises ArgumentError. The DSL enforces this via requires_one_of declarations at bind time.

Positional arguments

Arguments that are not options (e.g., file names, branch names) are passed as method arguments, not as keyword arguments.

  • Only single-valued positional arguments: If a command has one or more single-valued positional arguments (e.g., <arg1> or <arg1> <arg2>), pass each as a separate method argument, in the order they appear in the official git documentation and CLI usage. Optional arguments (indicated by [<arg>]) should default to nil.

    • git cmd <object>def cmd(object) (fictitious command)
    • git replace <object> <replacement>def replace(object, replacement)
    • git clone <repository> [<directory>]def clone(repository, directory = nil)
  • Single multi-valued positional argument: If a command has a single multi-valued positional argument (e.g., <pathspec>... or [<pathspec>...]), use a splat parameter to accept zero or more values (optional) or one or more values (required).

    • git add [<pathspec>...]def add(*paths)
  • Mixed single-valued and multi-valued positional arguments — -- separated (independently reachable groups): When a git command separates two optional groups with -- (e.g., [<tree-ish>] [-- <pathspec>...]), callers may want to supply the post--- group without supplying the first group. Use the single-valued argument as a regular optional parameter and the multi-valued group as a keyword argument with an empty array default. The keyword argument should accept a single value or an array; wrap a single value in an array internally.

    • git checkout [<branch>] [-- <pathspec>...]def checkout(branch = nil, pathspecs: [])
    • git diff [<tree-ish>] [-- <pathspec>...]def diff(tree_ish = nil, pathspec: [])
    • Callers can then do checkout(pathspecs: ['file.rb']) (no branch) or diff('HEAD~3', pathspec: ['file.rb']) (both), with no ambiguity.
  • Multiple optional single-valued positional arguments — pure nesting (second only meaningful with first): When the git SYNOPSIS shows nested optional brackets and the inner operand is only useful in the presence of the outer one, both arguments may be regular optional parameters in left-to-right order. A caller would never supply the second without the first.

    • git diff [<commit1> [<commit2>]]def diff(commit1 = nil, commit2 = nil)
    • Callers can do diff (no args), diff('HEAD~3'), or diff('HEAD~3', 'HEAD'). There is no case where someone would pass commit2 without commit1.

These conventions ensure the API is predictable and closely aligned with the git CLI. If a new option type is encountered, extend this section to document the mapping.

Output processing

The git gem translates the output of many Git commands into Ruby objects, making it easier to work with programmatically.

These Ruby objects often include methods that allow for further Git operations where useful, providing additional functionality while staying true to the underlying Git behavior.

When a single git command can produce distinctly different output types based on its options, implement separate methods for each output type. Follow the same naming convention used for commands with multiple purposes: use the git command name as a prefix, followed by a suffix that describes the specific output type or functionality.

For example, git diff can produce full diffs, statistical summaries, or path status information depending on the options used. These are implemented as separate methods:

repo.diff_full('HEAD~1', 'HEAD')       # Full diff output (git diff -p)
repo.diff_stats('HEAD~1', 'HEAD')      # Statistical summary (git diff --numstat)
repo.diff_path_status('HEAD~1', 'HEAD') # File paths and status (git diff --name-status)

This approach ensures each method has a clear, predictable return type and allows for targeted parsing logic appropriate to each output format.

From design to implementation

Note: Use this architecture for all new commands. The gem is being incrementally migrated using the "Strangler Fig" pattern:

  1. Phase 1 (completed): Foundation work to introduce the new command architecture and prepare the codebase for incremental migration.
  2. Phase 2 (current): New Git::Commands::* classes are created, and Git::Lib methods delegate to them. Git::Lib remains but becomes a thin wrapper.
  3. Phase 3 (planned): Public API (Git::Base) will be refactored to use Git::Commands::* directly, bypassing Git::Lib.
  4. Phase 4 (planned): Git::Lib will be removed entirely.

When adding new commands, create the Git::Commands::* class and have the corresponding Git::Lib method delegate to it (see Git::Lib#add for an example). When you encounter existing commands, you may optionally refactor them to this pattern following the TDD workflow.

The gem uses a three-layer architecture that separates the public API from internal implementation:

  1. Facade layer (Git::Base and Git module) — The current public interface. Methods here are thin wrappers that delegate to Git::Lib, which in turn delegates to internal command classes.

  2. Command layer (Git::Commands::*) — Internal classes that implement git commands. Each command class handles argument building and output parsing.

  3. Execution layer (Git::ExecutionContext) — Runs raw git commands. Command classes use this to execute git and receive output.

When wrapping a new git command:

  1. Design the public API using the guidelines in this section (placement, naming, parameters, output)

  2. Create a command class in lib/git/commands/ that:

    • Accepts an ExecutionContext and any required arguments
    • Defines arguments using the Arguments DSL
    • Parses the output into Ruby objects
  3. Add the facade method to Git::Base (or Git module) that delegates to Git::Lib.

Example structure for git add:

# lib/git/commands/add.rb (internal)
require 'git/commands/base'

module Git
  module Commands
    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
      #
      # @overload call(*paths, all: nil, force: nil)
      #
      #   @param paths [Array<String>] files to be added
      #
      #   @param all [Boolean] Add, modify, and remove index entries to match the worktree
      #
      #   @param force [Boolean] Allow adding otherwise ignored files
      #
      # @return [Git::CommandLineResult] the result of the command
      #
      def call(...) = super # rubocop:disable Lint/UselessMethodDefinition
    end
  end
end

How Base works: Base provides default #initialize (accepts an execution_context) and #call (binds arguments via the DSL, calls execution_context.command, validates exit status). Simple commands only need to declare arguments do … end and write def call(...) = super (which also serves as the YARD documentation anchor for per-command documentation). That forwarding method looks "useless" to RuboCop, so we disable Lint/UselessMethodDefinition on it; the method body must exist for YARD to attach command-specific docs, even though all work is delegated to Base#call.

Method Signature Convention: Most commands use def call(...) = super, which forwards all arguments to Base#call. Base#call binds them via the Arguments DSL, calls @execution_context.command(*args, **args.execution_options, raise_on_failure: false), and validates the exit status.

Override call explicitly in three situations:

  1. Input validation — guard ArgumentError for invalid option combinations that the DSL cannot express (e.g., empty operands without a compensating flag).
  2. Stdin via IO pipe — commands using the --batch / --batch-check protocol must feed object names to the subprocess's stdin. Use the inherited Base#with_stdin(content), which opens an IO.pipe, writes the string content, and yields the read end as in:. Do not open a pipe manually — StringIO is not accepted by Process.spawn (it has no file descriptor).
  3. Non-trivial option routing — when multiple call shapes require different argument sets built separately before dispatching.

When overriding, work with args_definition.bind(...) directly and delegate exit-status handling to the inherited validate_exit_status!. Extract bulk logic into private helpers to satisfy Rubocop Metrics thresholds:

def call(*objects, **options)
  raise ArgumentError, '...' if objects.empty? && !options[:batch_all_objects]

  bound = args_definition.bind(**options)
  with_stdin(objects.map { |o| "#{o}\n" }.join) { |reader| run_batch(bound, reader) }
end

private

def run_batch(bound, reader)
  result = @execution_context.command(*bound, in: reader, **bound.execution_options, raise_on_failure: false)
  validate_exit_status!(result)
  result
end

Validation of supported options is handled by the Arguments DSL, which raises ArgumentError for unsupported keywords. The public API in Git::Lib handles the translation from single values or arrays to the splat format.

YARD Documentation Note: When using anonymous keyword forwarding (**), YARD cannot infer the method signature. Use the @overload directive with explicit keyword parameters (e.g., @overload call(paths, all: nil, force: nil)) and document each keyword with its own @param tag. Do not use @option with @overload. See the example above for the pattern.

Testing Requirement: When defining arguments with the DSL, you must write RSpec tests that verify each argument handles valid values correctly (booleans, strings, arrays) and handles invalid values appropriately. Use a separate context block for testing each option to ensure clarity and isolation. See spec/unit/git/commands/add_spec.rb for examples of comprehensive argument testing.

# lib/git/lib.rb (delegation)
class Git::Lib
  # Git::Lib may accept an options hash for backward compatibility
  def add(paths = '.', options = {})
    # Convert to splat + keyword arguments when calling the command class
    Git::Commands::Add.new(self).call(*Array(paths), **options)
  end
end

# lib/git/base.rb (public facade)
class Git::Base
  def add(paths = '.', **options)
    lib.add(paths, options)
  end
end

For factory methods and non-repository commands, the pattern is similar but differs in how the ExecutionContext is obtained:

# Factory method (Git.clone) — creates context, runs command, returns repository
module Git
  def self.clone(url, path = nil, **options)
    # logic to call Git::Commands::Clone via Git::Lib
  end
end

# Non-repository command (Git.global_config) — standalone context
module Git
  def self.global_config(name, value = nil)
    Git::Lib.new.global_config(name, value)
  end
end

Note: The Git::Lib class currently acts as the execution context. In the new architecture, Git::Lib methods delegate to Git::Commands::* classes, passing self (the Git::Lib instance) as the execution context.

Example implementations

The following command classes demonstrate patterns for implementing new commands. See lib/git/commands/ and spec/unit/git/commands/ for the full implementations:

  • Simple command: Git::Commands::Add — straightforward argument building with the Arguments DSL
  • Command with output parsing: Git::Commands::Fsck — parses git output into structured Ruby objects
  • Factory command: Git::Commands::Clone — returns data for creating a repository object
  • Multiple outputs: Git::Commands::Diff::* — subclasses for different output formats (planned)
  • Multi-context: Git::Commands::Config — handles both module and instance variants (planned)

Coding standards

To ensure high-quality contributions, all pull requests must meet the following requirements:

Commit message guidelines

To enhance our development workflow, enable automated changelog generation, and pave the way for Continuous Delivery, the ruby-git project has adopted the Conventional Commits standard for all commit messages.

This structured approach to commit messages allows us to:

  • Automate versioning and releases: Tools can now automatically determine the semantic version bump (patch, minor, major) based on the types of commits merged.
  • Generate accurate changelogs: We can automatically create and update a CHANGELOG.md file, providing a clear history of changes for users and contributors.
  • Improve commit history readability: A standardized format makes it easier for everyone to understand the nature of changes at a glance.

What does this mean for contributors?

Going forward, all commits to this repository MUST adhere to the Conventional Commits standard. Commits not adhering to this standard will cause the CI build to fail. PRs will not be merged if they include non-conventional commits.

A git commit-msg hook (Husky + commitlint) that validates your Conventional Commit messages locally is installed automatically as part of the project bootstrap — see Local development setup. The hook depends on Node.js and npm; if those are not installed, bin/setup will warn and skip the hook, and commit-message validation will only run in CI.

What to know about Conventional Commits

The simplist conventional commit is in the form type: description where type indicates the type of change and description is your usual commit message (with some limitations).

  • Types include: feat, fix, docs, test, refactor, and chore. See the full list of types supported in .commitlintrc.yml.
  • The description must (1) not start with an upper case letter, (2) be no more than 100 characters, and (3) not end with punctuation.

Examples of valid commits:

  • feat: add the --merges option to Git::Lib.log
  • fix: exception thrown by Git::Lib.log when repo has no commits
  • docs: add conventional commit announcement to README.md

Commits that include breaking changes must include an exclaimation mark before the colon:

  • feat!: removed Git::Repository#commit_force

The commit messages will drive how the version is incremented for each release:

  • a release containing a breaking change will do a major version increment
  • a release containing a new feature will do a minor increment
  • a release containing neither a breaking change nor a new feature will do a patch version increment

The full conventional commit format is:

<type>[optional scope][!]: <description>

[optional body]

[optional footer(s)]
  • optional body may include multiple lines of descriptive text limited to 100 chars each
  • optional footers only uses BREAKING CHANGE: <description> where description should describe the nature of the backward incompatibility.

Use of the BREAKING CHANGE: footer flags a backward incompatible change even if it is not flagged with an exclaimation mark after the type. Other footers are allowed by not acted upon.

See the Conventional Commits specification for more details.

Issue and PR references

Due to a parser limitation in commitlint, using #<number> anywhere in the commit body causes everything from that line onward to be treated as a footer, which triggers a footer-leading-blank error.

To avoid this:

  • In the body, omit the # when mentioning an issue or PR — write issue 1000 not issue #1000.
  • In the footer, always include # for closing references: Closes #1000, Fixes #1000, or Resolves #1000.
  • If you only want to mention an issue for context (not close it), omit the # in the body — no footer line is needed.

To validate a commit message before committing:

npx commitlint --format @commitlint/format < commit_msg.txt

To see how commitlint has parsed a commit message:

cat commit_msg.txt | node -e "
const parse = require('@commitlint/parse');
let msg = '';
process.stdin.on('data', d => msg += d);
process.stdin.on('end', () =>
  parse.default(msg.trim()).then(r => console.log(JSON.stringify(r, null, 2)))
);
" | jq

Unit tests

  • All changes must be accompanied by new or modified unit tests.
  • The entire test suite must pass when bundle exec rake default is run from the project's local working copy.

This project uses two test frameworks:

  • RSpec (spec/) - Primary framework for all new tests.
  • Test::Unit (tests/units/) - Legacy test suite. Maintained for existing coverage but should not be extended for new features unless absolutely necessary.

RSpec best practices

  • Public methods: Use a separate describe '#method_name' block for each public method.
  • Contexts: Use separate context blocks for different scenarios.
  • Options: For methods accepting options (like commands), use a separate context for each option to ensure isolation and comprehensiveness.
  • One assertion per test: Each test should verify one specific aspect of behavior. Exceptions include: (a) testing that an object has expected attributes after creation (e.g., verifying multiple fields of a returned object), (b) verifying expected side effects of a single operation (e.g., a method that both returns a value and modifies state), (c) testing that multiple related assertions hold for the same setup (e.g., boundary conditions).

Unit tests vs Integration tests

This project uses two types of RSpec tests, organized by directory:

  • Unit tests (spec/unit/) - Test individual classes and methods with mocked execution context. These verify that the gem builds correct git command arguments and properly handles git output. Unit tests should mock @execution_context to avoid calling real git commands.

  • Integration tests (spec/integration/) - Test the gem's behavior against real git repositories. These verify that mocked assumptions in unit tests match actual git behavior. Integration tests create temporary repositories using Dir.mktmpdir and run real git commands through the gem's public API.

Purpose of integration tests: Integration tests validate that the gem correctly interacts with git, not that git itself works correctly. They should verify:

  • That the gem's mocked command expectations match real git output format
  • That the gem correctly handles real git behavior (e.g., unicode in branch names)
  • That command options produce expected git behavior
  • Edge cases that are difficult to mock reliably

Integration test guidelines:

  • Keep tests minimal and purposeful - only create what's needed for the test
  • Focus on key behaviors that unit tests can't verify
  • Don't test git's functionality - test the gem's interaction with git
  • Use the shared context 'in an empty repository' for temporary repo setup
  • Use Git::IntegrationTestHelpers methods for file operations
  • Each test should verify one specific git interaction pattern

Example: An integration test for branch listing should verify that the gem correctly parses git's branch list format, not that git can create branches.

While working on specific features, you can run tests using:

# Run all tests (TestUnit + RSpec):
$ bundle exec rake test_all

# Run only TestUnit integration tests:
$ bundle exec rake test

# Run only RSpec tests (unit + integration):
$ bundle exec rake spec

# Run only RSpec unit tests:
$ bundle exec rake spec:unit

# Run only RSpec integration tests:
$ bundle exec rake spec:integration

# Run a single TestUnit file (from tests/units):
$ bin/test test_object

# Run multiple TestUnit files:
$ bin/test test_object test_archive

# Run a specific RSpec file:
$ bundle exec rspec spec/unit/git/commands/add_spec.rb

# Run TestUnit tests with a different version of the git command line:
$ GIT_PATH=/Users/james/Downloads/git-2.30.2/bin-wrappers bin/test

New and updated public-facing features should be documented in the project's README.md.

Building a specific version of the Git command-line

To test with a specific version of the Git command-line, you may need to build that version from source code. The following instructions are adapted from Atlassian’s How to install Git page for building Git on macOS.

Install pre-requisites

Prerequisites only need to be installed if they are not already present.

From your terminal, install Xcode’s Command Line Tools:

xcode-select --install

Install Homebrew by following the instructions on the Homebrew page.

Using Homebrew, install OpenSSL:

brew install openssl

Obtain Git source code

Download and extract the source tarball for the desired Git version from this source code mirror.

Build git

From your terminal, change to the root directory of the extracted source code and run the build with following command:

NO_GETTEXT=1 make CFLAGS="-I/usr/local/opt/openssl/include" LDFLAGS="-L/usr/local/opt/openssl/lib"

The build script will place the newly compiled Git executables in the bin-wrappers directory (e.g., bin-wrappers/git).

Use the new Git version

To configure programs that use the Git gem to utilize the newly built version, do the following:

require 'git'

# Set the binary path
Git.configure { |c| c.binary_path = '/Users/james/Downloads/git-2.30.2/bin-wrappers/git' }

# Validate the version (if desired)
assert_equal(Git::Version.new(2, 30, 2), Git.git_version)

Tests can be run using the newly built Git version as follows:

GIT_PATH=/Users/james/Downloads/git-2.30.2/bin-wrappers bin/test

Note: GIT_PATH refers to the directory containing the git executable.

Licensing

ruby-git uses the MIT license as declared in the LICENSE file.

Licensing is critical to open-source projects as it ensures the software remains available under the terms desired by the author.