Repository navigation
Conversation
lyrixx
force-pushed
the
dto
branch
2 times, most recently
from
October 12, 2026 07:27
1ee409e to
9962093
Compare
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.
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
marked this pull request as ready for review
October 13, 2026 12:09
This was referenced Oct 14, 2026
Closed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This is gitlib 2.0. Every git operation goes through a driver, and there are two of them:
CliDriver, thegitbinary and still the default, andLibGit2Driver, libgit2 1.9 in-process through PHP's FFI extension.LibGit2Driveris 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:
main, so it can be backported to 1.x.LibGit2Driveraligned with git. Its message lists the changes by theme.Design
Driver\DriverInterfaceis 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\RepositoryDriverInterfaceholds the operations on one repository, andopen()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 aDriverExceptionwhen git fails, anUnsupportedOperationExceptionwhen the driver can't do the operation, and anInvalidArgumentExceptionfor 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 aRepositorywithout any git repository.Repositorycreate repositories, and replaceAdmin:init(),initBare(),cloneFrom($url, $path, $branch),cloneBareFrom()andmirrorFrom(), plusisValidRepository()andisValidRepositoryAndBranch(), which matches the name of the branch exactly.ReferenceNotFoundException, and a timeout aProcessTimedOutException. Every message is a sentence.run()andrunProcess()remain the CLI-only escape hatch.Main API changes
Repository::getDiff($from, $to)compares two commits, andgetMergeBase()gives the base ofa...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/DiffLineTypereplace the arrays, and aFilegets its changes and its repository when constructed.Logis immutable (withOffset(),withLimit()), the domain resolves its revisions, andcount()agrees with the iteration.ReferenceData).createBranch()/createTag()refuse an existing reference. New:Branch::moveTo()andisMergedInto(), and tags signed with SSH or X.509 are read.Blame\Hunk. Trees haveTreeEntry,Submoduleand theObjectTypeenum. The working copy hascheckout(Revision|string),checkoutNewBranch()and merges with theFastForwardenum.\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 withWeakReference,getTree()/getBlob()take abbreviated hashes and refuse an object of another type, and SHA-256 repositories are refused.InvalidArgumentException, where libgit2 read it as a shorter C string.CliDrivertakes noinheritEnvironmentVariables, which passed$_SERVER, the credentials of HTTP requests included, to git: the deprecated option keeps its 1.x behavior.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();RepositoryandsetLogger()/getLogger();getDiff();Log::getDiff(), the array access of diff lines,shell(),getShortMessage()andgetFixedShortHash();checkout(),merge()andgetBlame().A test calling deprecated code fails unless it is
#[IgnoreDeprecations]: the legacy tests check every message.LibGit2DriverIt reproduces git wherever its tests reach:
Documented limits:
merge.log;http://URL;With php-fpm,
LibGit2Driver::preload()keeps libgit2 loaded fromopcache.preload.benchmark.phpcompares 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
tests-ffijob builds libgit2 1.9.1 and runs the suite with it, and atests-gitjob builds git 1.8.4 and 2.25.1 and runs it with each. Tests clean up after themselves, in any order.check-archivechecks 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:
core.hooksPath.