Skip to content

gitlib 2.0: CLI and libgit2 drivers, and a typed API - #253

Merged
lyrixx merged 9 commits into
mainfrom
dto
Oct 14, 2026
Merged

lyrixx merged 9 commits into
mainfrom
dto

Conversation

@lyrixx

@lyrixx lyrixx commented Oct 11, 2026 •

Copy link
Copy Markdown
Member

This is gitlib 2.0. Every git operation goes through a driver, and there are two of them: CliDriver, the git binary and still the default, and LibGit2Driver, libgit2 1.9 in-process through PHP's FFI extension. LibGit2Driver is experimental and not covered by the backward compatibility promise. The domain API is typed and consistent across the library, and the 1.x API keeps working with a deprecation wherever that was possible.

UPGRADE-2.0.md lists every change, with a before/after for each deprecation. CHANGELOG.md splits breaking changes, features, deprecations and fixes. Every page of doc/ was rewritten for 2.0, and its examples were run with both drivers.

Commits

The PR reads commit by commit:

  1. Parse the diffs of git before 2.16: on the code of main, so it can be backported to 1.x.
  2. Extract git access behind a driver, with a CLI driver.
  3. Add an experimental LibGit2Driver, running libgit2 in-process.
  4. Deprecate Admin and the git options of Repository.
  5. Parse only diffs with their raw information: deprecated since 1.6.0.
  6. Make the tests clean up after themselves, in any order.
  7. Redesign the domain API for 2.0: repositories, references, diffs, logs, blames, trees, the working copy, exceptions, NUL bytes, and LibGit2Driver aligned with git. Its message lists the changes by theme.
  8. Test old git versions, and analyse the tests and the libgit2 driver: CI and static analysis.
  9. Document the 2.0 API.

Design

  • Driver\DriverInterface is the configured backend, the one you inject. It opens repositories (open()) and runs what needs no repository yet: init, clone and remote checks. It holds no state of a repository, so one instance (a container service, for instance) serves every repository. It is configured through its constructor:
    • new CliDriver(command: ..., environmentVariables: ..., processTimeout: ..., logger: ...)
    • new LibGit2Driver('/path/to/libgit2.so.1.9')
  • Driver\RepositoryDriverInterface holds the operations on one repository, and open() returns it. It returns value objects (Driver\Data\*: commits, diffs, references..., with enums and hashes), and the domain builds its objects from them. It throws a DriverException when git fails, an UnsupportedOperationException when the driver can't do the operation, and an InvalidArgumentException for an argument holding a NUL byte. Both drivers find the git directory and the working tree with the same code, so linked worktrees, submodules and reftable repositories all open.
  • new Repository($dir, ?$workingDir, $driver = new CliDriver()) is the injection point, tests included: a stub of both interfaces runs a Repository without any git repository.
  • Static methods of Repository create repositories, and replace Admin: init(), initBare(), cloneFrom($url, $path, $branch), cloneBareFrom() and mirrorFrom(), plus isValidRepository() and isValidRepositoryAndBranch(), which matches the name of the branch exactly.
  • The domain resolves revisions, builds logs and diffs, and never swallows an exception: a missing revision or object throws a ReferenceNotFoundException, and a timeout a ProcessTimedOutException. Every message is a sentence. run() and runProcess() remain the CLI-only escape hatch.

Main API changes

  • Diffs: Repository::getDiff($from, $to) compares two commits, and getMergeBase() gives the base of a...b. WorkingCopy::getDiff() compares the working tree. The files of a diff come from its raw information (--raw -z), and the diff of a merge commit is against its first parent. DiffLine/DiffLineType replace the arrays, and a File gets its changes and its repository when constructed.
  • Logs: Log is immutable (withOffset(), withLimit()), the domain resolves its revisions, and count() agrees with the iteration.
  • References: they are read again after every write gitlib makes, and annotated tags are peeled (ReferenceData). createBranch()/createTag() refuse an existing reference. New: Branch::moveTo() and isMergedInto(), and tags signed with SSH or X.509 are read.
  • Blame, trees, working copy: blame lines are numbered from the first line of the range, and there is Blame\Hunk. Trees have TreeEntry, Submodule and the ObjectType enum. The working copy has checkout(Revision|string), checkoutNewBranch() and merges with the FastForward enum.
  • Data: dates are \DateTimeImmutable, and names in diffs and trees are UTF-8. The subject of a message is its first paragraph, like git's %s. Objects are cached with WeakReference, getTree()/getBlob() take abbreviated hashes and refuse an object of another type, and SHA-256 repositories are refused.
  • Security: an argument holding a NUL byte throws an InvalidArgumentException, where libgit2 read it as a shorter C string. CliDriver takes no inheritEnvironmentVariables, which passed $_SERVER, the credentials of HTTP requests included, to git: the deprecated option keeps its 1.x behavior.
  • Requirements: PHP 8.4 and git 1.8.4 or later.

Deprecations, removed in 3.0

Like Symfony does, the API of 1.x keeps working through trigger_deprecation(), and each message names its replacement. This covers:

  • Admin, cloneTo();
  • the options array of Repository and setLogger()/getLogger();
  • the one-argument forms of getDiff();
  • Log::getDiff(), the array access of diff lines, shell(), getShortMessage() and getFixedShortHash();
  • the old signatures of checkout(), merge() and getBlame().

A test calling deprecated code fails unless it is #[IgnoreDeprecations]: the legacy tests check every message.

LibGit2Driver

It reproduces git wherever its tests reach:

  • clones and mirrors;
  • log order and history simplification, checked against git on symfony/symfony;
  • checkout and merge validation;
  • rename detection settings;
  • tag signatures and encodings.

Documented limits:

  • no logging and no timeout;
  • no SSH, no credential helpers, no bundles, no reftable, no hooks and no external filters;
  • merges are limited to fast-forward or conflict-free ones, and ignore merge.log;
  • no proxy for a plain http:// URL;
  • no DWIM checkout of remote branches;
  • blame is slow on a long history.

With php-fpm, LibGit2Driver::preload() keeps libgit2 loaded from opcache.preload. benchmark.php compares both drivers, and the results are in doc/repository.md. Objects read 2 to 28 times faster. Long history walks are up to 60 times slower, because libgit2 has no commit-graph.

Tests and CI

  • Both drivers pass the whole suite. A tests-ffi job builds libgit2 1.9.1 and runs the suite with it, and a tests-git job builds git 1.8.4 and 2.25.1 and runs it with each. Tests clean up after themselves, in any order.
  • A test compares what both drivers read from the same repository: references, history, trees, blobs, tags, diffs and blames.
  • PHPStan analyses the tests and the libgit2 driver too, with phpstan-phpunit, and PHPUnit fails on notices and on its own deprecations.
  • check-archive checks that the Composer archive only ships the code, the docs and the license. Dependabot updates the actions and the tools.

Out of scope, for later:

  • argument and URL injection, credentials in messages, hook names;
  • isolating the tests from the user's git configuration;
  • core.hooksPath.

@lyrixx
lyrixx force-pushed the dto branch 2 times, most recently from 1ee409e to 9962093 Compare October 12, 2026 07:27
Before 2.16, `git diff --raw` writes "..." after abbreviated hashes,
which the diff parser rejected: Repository::getDiff(),
WorkingCopy::getDiffPending() and getDiffStaged() failed with every git
before 2.16. --no-abbrev writes full hashes on every version.
Every git operation goes through a driver, which returns value objects
(Driver\Data) instead of building processes and parsing their output
across the domain classes. Repository and Admin take the driver as their
last argument: Driver\Cli\CliDriver by default, which runs the git binary
like before, and is configured through its constructor instead of the
"command", "environment_variables", "inherit_environment_variables" and
"process_timeout" options. A Driver\DriverInterface opens each repository
as a Driver\RepositoryDriverInterface, and runs the operations that need
no repository (init, clone...): a stub of both replaces git in tests.

A failing git operation always throws: the "debug" option is removed,
and nothing returns null or empty data instead anymore. Unknown options
throw too. Dates are \DateTimeImmutable, file names in diffs and trees
are returned in UTF-8, and git 1.8.4 is required. UPGRADE-2.0.md lists
every change, and the CHANGELOG the bugs this fixes.
Driver\Ffi\LibGit2Driver calls libgit2 1.9 through PHP's FFI extension,
instead of starting a git process for every operation: it reads objects
up to 28 times faster, but walks long histories slower, as libgit2
doesn't use git's commit-graph, and its blame is very slow
(libgit2#3027). It reproduces git's behavior, notably how git log
simplifies the history of a path, and is checked by running the whole
suite with it in CI (GITLIB_DRIVER=ffi).

It is experimental: it isn't covered by the backward compatibility
promise. With php-fpm, LibGit2Driver::preload() keeps libgit2 loaded
from opcache.preload. benchmark.php compares both drivers on a
repository.
Admin only forwarded to the driver: Repository gets static methods
instead, one for each kind of repository rather than a $bare argument,
taking the URL before the path: init(), initBare(), cloneFrom(),
cloneBareFrom(), mirrorFrom(), isValidRepository() and
isValidRepositoryAndBranch(). Repository::cloneTo() is deprecated too.
DriverInterface replaces cloneRepository() and its arguments of git
clone with cloneFrom(), cloneBareFrom() and mirrorFrom().

Like Symfony does, the API of 1.x is deprecated instead of removed, until
3.0: Admin keeps its signatures of 1.x, and the "command",
"environment_variables", "inherit_environment_variables" and
"process_timeout" options of Repository and Admin configure a CliDriver
again, through the internal Driver\Cli\LegacyOptions. The "debug" option
is deprecated, and refused when false. Each deprecation names the exact
replacement, through symfony/deprecation-contracts.

A test calling deprecated code fails, unless it is #[IgnoreDeprecations]:
the legacy tests check every deprecation message, and that Admin behaves
like in 1.x.
Parsing a diff without its raw information has been deprecated since
1.6.0 (#229): Diff::parse() now throws an InvalidArgumentException for
it. The raw information gives the full hashes of the files, even empty
ones; gitlib always asks git for it.
Each test deletes the directories it created, instead of a shutdown
function that leaked every directory given to a failing call, and the
local fixture of a class is deleted after its last test. The data
providers give the "bare" argument instead of cloning every repository
when the suite loads, and the local fixture is always bare: a test class
run alone, or in random order, no longer depends on the class that ran
first. deleteDir() no longer follows symbolic links out of the directory.

The legacy test of Admin::cloneRepository() passed --bare with --origin,
which git only accepts since 2.38.2: it passes --config instead.
@lyrixx lyrixx changed the title Add a driver abstraction, with CLI and libgit2 (FFI) drivers gitlib 2.0: CLI and libgit2 drivers, and a typed API Oct 13, 2026
Repositories
- The logger configures CliDriver, which logs every git command it runs,
  init, clone and ls-remote included. The constructor of Repository
  takes the working directory as second argument: an array of options
  there is deprecated, its logger and git options configuring a
  CliDriver, and so are setLogger() and getLogger().
- CliDriver takes no inheritEnvironmentVariables, which passed $_SERVER,
  the credentials of an HTTP request included, to git: the deprecated
  option applies to the processes of a repository, like in 1.x, and not
  to the commands of Admin, which ignored it.
- Both drivers find the git directory and the working tree with the
  same code, a ".git" file included: linked worktrees and submodules
  open. A directory that isn't a repository throws an
  InvalidArgumentException right away, and a SHA-256 repository an
  UnsupportedOperationException. HEAD is read through the driver, not
  from the HEAD file, which a reftable repository doesn't use.
- isValidRepositoryAndBranch() matches the name of the branch exactly:
  "release" matched "feature/release". isValidRepository() reads a
  relative path as a path, not as a remote of PHP's working directory,
  and throws when git can't run. A clone checks a tag given as branch
  out, HEAD detached, with both drivers.

References
- ReferenceBag keeps one list, read again after every write gitlib makes
  (its own, a checkout, a merge, run()...): a tag it created missed from
  getTags(), a deleted branch stayed, and HEAD pointed to an unknown
  branch after a checkout. Passing $reload to getReferences() is
  deprecated.
- A reference holds the commit it points to, through an annotated tag
  (show-ref --dereference): resolveTags(), resolve() and
  Commit::resolveReferences() find annotated tags, and resolve() takes a
  revision name too. The getters of a tag return null instead of false,
  and SSH and X.509 signatures are recognized.
- createBranch() and createTag() refuse an existing reference instead of
  moving it, and take a revision. Branch::moveTo() replaces
  ReferenceBag::update(), and isMergedInto() isMergedTo(); both are
  deprecated, as the arguments of Commit::getIncludingBranches(). A
  branch named "remotes/x" is local, and only the HEAD of a remote, named
  "foo/bar" or not, is skipped, not every branch ending with HEAD.
  Deleting a missing reference does nothing, like git update-ref -d.

Diffs
- Repository::getDiff() takes the commits to compare, $from and $to, and
  getMergeBase() gives the base of "a...b": calling getDiff() with one
  argument is deprecated, as Log::getDiff(), which ignored the paths of
  the log. WorkingCopy::getDiff() compares the working tree with a
  revision. The diff of a commit is against its first parent, with both
  drivers: the CLI one threw for most merges, and the libgit2 one
  concatenated a diff for each parent.
- DiffParser reads the files from the raw information (with -z or not),
  and the hunks only from the patches: names with spaces were split at
  the wrong place, hashes were shifted after a type change, a hunk of one
  line was counted as empty, and diffs threw during a conflict. During a
  conflict, WorkingCopy::getDiff() compares the unmerged files to the
  revision, like git diff HEAD, and the other diffs leave them out.
- The drivers return DiffData, which the domain builds a Diff from, with
  its repository. Diff, File and FileChange are given their changes when
  constructed: setRepository() and addChange() are deprecated. The lines
  of a hunk are DiffLine objects, with a DiffLineType; reading them as
  arrays is deprecated, as FileChange::getCount() and its LINE_*
  constants, File::isChangeMode(), isDelete() and getAnchor().

Logs
- Log resolves its revisions, ranges, names to exclude and "--all" once,
  and gives the driver commits to include and to exclude: the libgit2
  driver no longer reimplements git's syntax, and an option is refused
  instead of being passed to git. The driver returns a list of
  CommitData, each with its hash.
- withOffset() and withLimit() return another Log: setOffset() and
  setLimit() are deprecated. count() is the number of commits the log
  iterates.
- The log parser reads the headers of a commit by their format: the merge
  of an unsigned or SSH-signed tag threw, or swallowed the commits up to
  the next PGP signature.

Blames, trees and the working copy
- getBlame() takes the first and the last lines to blame, numbered from
  the blamed file, a range as a string being deprecated, and getHunks()
  returns Blame\Hunk objects. Like git, the lines 5 to 3 are the lines 3
  to 5. LibGit2Driver counted one line too many in a file not ending
  with a line break.
- A tree lists TreeEntry objects, with a Blob, a Tree or a Submodule,
  which CommitReference becomes, typed by an ObjectType enum. Reading an
  entry as an array is deprecated. resolvePath() explains why a path
  doesn't resolve.
- WorkingCopy::checkout() takes a Revision: a local Branch attaches HEAD.
  checkoutNewBranch() replaces its second argument, and merge() takes a
  FastForward enum instead of arguments of git merge, both deprecated.

Consistency
- Both drivers throw a ReferenceNotFoundException, now an
  InvalidArgumentException of gitlib, when an object doesn't exist or is
  of another type, a ProcessTimedOutException on timeout, and a
  DriverException for the other failures of Symfony Process: no
  exception of PHP or Symfony leaks anymore, a date out of range
  included, and every message is a sentence.
- An argument holding a NUL byte throws an InvalidArgumentException:
  libgit2 read "README.md\0.php" as README.md, and deleting
  "refs/heads/tmp\0x" deleted refs/heads/tmp.
- getTree() and getBlob() take hashes only, abbreviated or not, of an
  object of the right type: CliDriver reads objects with cat-file
  --batch, which gives their type, where ls-tree read the tree of a
  commit. The caches of commits, trees and blobs keep weak references,
  and forget the hashes of the objects freed.
- Blob::getMimetype() returns the mimetype alone, or throws, instead of
  false.
- The subject of a commit or a tag is its first paragraph, and its body
  what follows, like git log --format=%s and %b.
- Hooks create the hooks directory, write those of a linked worktree in
  its repository, and throw a RuntimeException when they fail.
- PushReference runs git only when isForce() needs it; getFullname() and
  getRange() replace getReference() and getRevision().
- Repository::shell(), Commit::getShortMessage() and getFixedShortHash()
  are deprecated.

LibGit2Driver, aligned with git where it differed
- checkout() checks out a branch before a tag of the same name, and
  validates everything before changing the working tree;
- clones refuse a directory with files and clean up when they fail; a
  mirror doesn't fetch tags twice, and no clone leaves a FETCH_HEAD;
- a merge refuses unrelated histories and staged changes, and signs with
  GIT_AUTHOR_* and GIT_COMMITTER_*;
- diffs use the indent heuristic, network operations the proxy git
  would use, references are sorted by name, and "." is the root;
- a repository removed meanwhile throws, instead of having no
  reference, and so does an absolute path outside the working tree;
- a libgit2 built for SHA-256 is refused.

Tests
- A test compares what both drivers read from the same repository:
  references, history, trees, blobs, tags, diffs and blames.
- The tests disable the background maintenance of git, which wrote in a
  repository a test was deleting.
CI builds git 1.8.4, the oldest supported version, and 2.25.1, and runs
the suite with them. The workflow only reads the repository, checks the
tarballs it builds, keys the libgit2 cache on its build flags, uses
actions/cache 6, checks that the archive Composer installs only ships
the code, the docs and the license (export-ignore), and Dependabot
updates the actions and the tools.

PHPStan analyses the tests, with phpstan-phpunit, and the libgit2
driver, ignoring only the calls to FFI it can't see. PHP-CS-Fixer
applies php_unit_strict. PHPUnit fails on notices and on its own
deprecations. psr/log, in the API, is required. The fixture's refs are
pinned: they must never move.
Every page of doc/ describes the API of 2.0, its errors, and what
differs between the drivers, with examples run against both. The
deprecated forms are mentioned once, pointing to UPGRADE-2.0.md, which
now lists every change, with a before/after for each deprecation. The
CHANGELOG splits breaking changes, features, deprecations and fixes.
The README installs 2.0 and shows how to choose the driver.
@lyrixx
lyrixx marked this pull request as ready for review October 13, 2026 12:09
@lyrixx
lyrixx merged commit 6c8f58c into main Oct 14, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant