Skip to content

Use libgit2 directly instead of ObjectiveGit - #727

Open
mssun wants to merge 16 commits into
masterfrom
claude/libgit2-direct-integration-692e0e
Open

Use libgit2 directly instead of ObjectiveGit#727
mssun wants to merge 16 commits into
masterfrom
claude/libgit2-direct-integration-692e0e

Conversation

@mssun

@mssun mssun commented Aug 3, 2026

Copy link
Copy Markdown
Owner

Replaces the ObjectiveGit dependency with libgit2 called directly.

ObjectiveGit was last released as 0.18 in 2019 and reached the app as a prebuilt xcframework hosted on a personal release, so the wrapper, the libgit2 inside it and the schedule for updating either were all pinned to that artifact. The git layer now calls libgit2 1.9.6, built from source by a script in this repository.

How it is split

Each commit builds and passes the tests on its own.

Replacing the backend

  1. 94f77cc confines the backend behind value types. GTCommit and GTSignature crossed PasswordStore into the controllers, the clone, pull and push options were untyped dictionaries carrying a GTCredentialProvider, and the progress handlers took raw libgit2 pointers. Because passKit re-exports ObjectiveGit, the controllers used those types without importing anything, which hid the coupling from a search for imports.
  2. 38b018a reimplements GitRepository and GitCredential on the C API.
  3. 693e2e7 adds scripts/libgit2_build.sh.
  4. 0db2d84 links the result and deletes the package.
  5. 459a569 builds libgit2 in CI.
  6. 22a6c3c records why HTTPS stays on TLS 1.2, and stops rebuilding OpenSSL on every run.

Fixing what review and the tests found

  1. f118338 fixes six findings from a review of the branch.
  2. adfc242 drops fileprivate, which only fails the build under --strict, which only CI passes.
  3. 7741055 accepts SSH host keys. See below.

Covering the transports

  1. 7961ca6 adds scripts/git_servers.sh and the transport tests.
  2. 2132eff through 528536b get those servers working on a runner.

What had to be written by hand

libgit2 has no pull. It is assembled here as fetch, then merge analysis, then either nothing when already up to date, a reference update when the merge is a fast-forward, or a merge commit. getLocalCommits, numberOfCommits and the blame behind the "last updated" text are revwalks and a blame walk rather than wrapper conveniences.

Credentials no longer depend on the backend at all: GitCredential produces a value describing what to authenticate with, and only the callback in Libgit2.swift turns that into a git_credential.

The bug this nearly shipped with

libgit2 1.9 verifies the host key of an SSH remote against a known_hosts file and refuses the connection when it finds no match. iOS has no such file, so every SSH remote failed with "invalid or unknown remote ssh hostkey". The 0.28 inside the wrapper never verified host keys at all.

Nothing caught it. It passed a full review, 161 tests and green CI, because every test cloned from a repository on disk. It is why the transport tests exist, and it was the first thing they found.

Host keys are now accepted, restoring the behaviour the app has always had. That means SSH has no protection against a machine in the middle, as it did not before either. Verifying host keys properly means storing them and asking on first connect, which is worth doing and is a feature rather than part of swapping a backend.

Also fixed

  • A push the remote refuses is now reported. The wrapper returned success and nothing in the app noticed.
  • A merge that does not reach a commit is undone, so a conflict no longer leaves an unmerged index that makes every later save fail with "cannot create a tree from a not fully merged index".
  • A refused push no longer deletes the stored git password, which is not what failed.
  • The git config screen validated the name twice instead of the address in the email field.

Testing

161 tests before, 170 now. pull, reset and checking out a branch that only exists on the remote were not covered. The SSH credential test that had been skipped with "failed in CI, reason unknown" runs again, since it no longer depends on a live handshake.

scripts/git_servers.sh starts an sshd and a git-http-backend behind TLS and seeds a repository for each. Clone, pull and push are covered over both transports, along with a refused password, a certificate that is not the one expected, and a provider that gives up, which stands for the user dismissing the passphrase prompt. Without the servers those tests skip and the rest of the suite is unaffected.

The tests pin the certificate of the server rather than adding it to the trusted roots of the simulator: simctl reports that it added it, and on a runner the trust does not take effect. The pin sits behind DEBUG, so a release build has no trace of it, and one test points it at a different certificate and requires the connection to be refused, so it cannot quietly become "accept anything". What this does not cover is validation against the trust store of the system.

Trade-offs worth knowing

HTTPS stays on TLS 1.2. libgit2 hardcodes that ceiling in its SecureTransport stream. Patching the constant does not work: SecureTransport on iOS rejects kTLSProtocol13 with errSSLIllegalParam, and libgit2 aborts the connection when that call fails, so the patch would stop HTTPS working rather than fall back to 1.2. Measured on the simulator, where a handshake with a host that offers TLS 1.3 settles on 1.2 regardless. TLS 1.3 would mean using OpenSSL for HTTPS and shipping a CA bundle with the app, since OpenSSL cannot read the trust store of the system. This is not a change: the libgit2 0.28 inside ObjectiveGit had the same ceiling.

Two build scripts are needed before Xcode will build, as the README says. A cold libgit2 build is three OpenSSL compiles. CI caches the finished xcframework, so it is paid once per change to the build script. If that is too slow, publishing the xcframework to a release behind an SPM binaryTarget removes it from CI entirely, and the script already produces the artifact that would be uploaded.

The German and Italian strings added for two new messages are not from a translator and deserve a native check.

🤖 Generated with Claude Code

mssun and others added 16 commits August 2, 2026 11:57
ObjectiveGit types formed part of the public API of passKit: GTCommit and
GTSignature crossed PasswordStore, the clone, pull and push options were
untyped dictionaries carrying a GTCredentialProvider, and the progress
handlers took raw libgit2 pointers. passKit re-exports ObjectiveGit, so
the controllers used GTSignature, git_transfer_progress and
GTPullMergeConflictedFiles without importing anything, which hid the
coupling from a search for imports.

Introduce value types in GitTypes.swift -- GitCommit, GitSignature and
the three progress structs -- and express the public surface of
GitRepository in terms of them. The option dictionaries become an opaque
GitCredentialOptions, and signature validation and merge conflict details
are reached through GitSignature.isValid and GitError.mergeConflictPaths,
so that the two remaining rules of libgit2 they depend on stay in the
backend. Conversion lives in a bridging section at the bottom of
GitRepository.swift.

Progress fractions are computed by the progress types instead of at every
call site, which also avoids dividing by a total that is still zero.

ObjectiveGit is now imported by GitRepository.swift and
GitCredential.swift alone, plus their tests, which exercise that layer
directly. Replacing the backend no longer reaches beyond those files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Objective-C wrapper is no longer used. GitRepository and GitCredential
now call libgit2 directly, and Libgit2.swift holds what the C API needs
around it: the one-time library initialisation, translation of return
codes into errors carrying the message libgit2 produced, conversion of
commits and signatures into the value types, and the callback bridge that
carries the Swift progress and credential handlers through the void
pointer payload of the libgit2 callbacks.

libgit2 has no pull, so it is assembled here: fetch, then merge analysis,
then either nothing when already up to date, a reference update when the
merge is a fast-forward, or a merge commit. Conflicts abort the merge,
clean up the repository state and report the conflicting paths, as
before. Push reports references the remote refused, which the wrapper did
and nothing in the app noticed until now.

Credentials no longer depend on the backend at all. GitCredential
produces a value describing what to authenticate with, and only the
callback in Libgit2.swift turns that into a git_cred. A user name is
answered when libgit2 asks for one before the credential itself, which
the ObjectiveGit provider never did.

The callback context is passed to libgit2 unretained, so every call that
uses it runs inside withExtendedLifetime.

Errors are Swift values now and carry no NSUnderlyingErrorKey, so the
sync error handler reads the libgit2 message itself when looking for a
rejected SSH passphrase, and takes the conflicting paths from the error
rather than from a userInfo key.

The libgit2 headers still come from the ObjectiveGit framework, which
bundles the library. Building libgit2 separately is what remains to drop
the dependency; no Swift outside these files is involved in that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds the build that has to replace the prebuilt ObjectiveGit framework,
which is where libgit2 currently comes from. It produces an xcframework
holding libgit2 statically linked against libssh2 and the OpenSSL
libcrypto libssh2 needs, for arm64 and both simulator architectures,
alongside the git2 headers and a module map so that it can be imported
from Swift. Output lands in a gitignored directory, as the gopenpgp build
next to it does.

The configuration follows what the framework being replaced actually
does, read off its symbol table rather than assumed: HTTPS goes through
SecureTransport, so OpenSSL is only ever reached by the SSH transport.
That keeps ed25519 keys and keys held in memory working, and avoids
having to ship a certificate bundle, which using OpenSSL for TLS would
require since it cannot reach the system trust store.

Two settings are not the obvious ones. The architecture is selected by
picking the matching OpenSSL target rather than by passing -arch, because
Configure reads an argument that does not start with a dash as a second
target and fails. And the regular expression backend is the PCRE bundled
with libgit2, because regcomp_l, which CMake chooses by default on Apple
platforms, is marked unavailable on iOS.

libgit2 is pinned to 1.9.6, which still provides the names the git layer
uses -- git_cred_*, git_transfer_progress and the *_init_options
functions -- as deprecated aliases. Building it with DEPRECATE_HARD would
remove them, so it is left off deliberately.

Nothing consumes the xcframework yet; the project still links ObjectiveGit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The project now links the xcframework built by scripts/libgit2_build.sh,
referenced from its build directory the way the GopenPGP one next to it
is, and the ObjectiveGit package is gone. It is linked into passKit and
the two extensions, which is where the git layer actually lives; the
package had been attached to the app target instead.

libgit2 is built with iconv and zlib, so both are linked alongside it.
Nothing complained about zlib because another dependency happened to pull
it in, which is not something to rely on.

Now that the libgit2 version is ours to choose, the git layer uses the
current names rather than the ones kept for compatibility:
git_indexer_progress, git_credential_* and the *_options_init functions.
This was forced by GIT_CREDENTIAL_USERNAME, whose compatibility spelling
is a macro over an enum constant and therefore invisible to Swift, but
the rest are renamed too so that nothing depends on deprecated aliases
staying available.

The bridging header of the app target existed only to import ObjectiveGit
and is now empty. The build setting pointing at it is left in place.

libgit2, libssh2 and OpenSSL replace ObjectiveGit in the list of open
source components, as all three are now linked into the app.

Building the app needs scripts/libgit2_build.sh to have been run, as it
already needs the GopenPGP one; the README says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
libgit2 caps its SecureTransport stream at TLS 1.2. Raising it looks like
a one line change, but SecureTransport on iOS rejects kTLSProtocol13 with
errSSLIllegalParam, and libgit2 sets the maximum version inside a chain of
calls it aborts the connection on, so the patch would not fall back to TLS
1.2 -- it would stop every HTTPS remote from connecting. Measured on the
simulator, where a handshake with a host that offers TLS 1.3 settles on
1.2 in any case. None of the tests would have caught it, as they all use
file:// remotes.

Reaching TLS 1.3 means using OpenSSL for HTTPS and shipping a CA bundle
with the app, since OpenSSL cannot read the trust store of the system.
That trade is written down next to the option it applies to.

Also stop rebuilding OpenSSL on every run. It is pinned and takes longer
than everything else combined, so an existing install is now reused.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both workflows built GopenPGP but nothing built libgit2, so every run
would have failed on the missing xcframework as soon as Xcode opened the
project.

Only the finished xcframework is cached, not the whole build tree as the
GopenPGP step does. The tree is 666M, almost all of it OpenSSL sources and
object files, against 35M for the framework itself, and nothing outside
libgit2/dist is needed to build the app -- verified by building and
testing with every other directory moved away, which is the state a cache
hit leaves behind.

CMake is already on the macOS runners, so no tooling step is needed. The
prepare lane builds libgit2 too, so that it still does what its name says.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…lures

A merge that is not carried through to a commit left conflicts in the
index and a half-merged work tree. Nothing cleaned that up, so every
later commit failed in git_index_write_tree with "cannot create a tree
from a not fully merged index", and the app could no longer save any
password change until the user found the discard action. The same held
when the merge commit itself could not be created, with the added twist
that MERGE_HEAD survived and the next commit recorded the merged content
against HEAD alone, losing the second parent. The merge is now undone
unless it reaches a commit, which resets the index and work tree to HEAD
and leaves the local commits alone.

A push the remote refuses is reported since this branch, and the sync
handler deleted the stored password for any error at all, so a protected
branch or a push race threw away a credential the remote had just
accepted. Only failures that can plausibly be blamed on the credential
delete it now.

The credential callback returned -1 without leaving a message, so
cancelling the passphrase prompt surfaced whatever an earlier operation
had put in the error slot of libgit2, or "Git error -1". It now says what
happened. git_error_set_str lives in a header git2.h does not include, so
the module map exposes it and the framework has to be built again.

Also assign the branch name only once the branch is known to exist, and
validate the address in the email field rather than the name twice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The SwiftLint build phase only passes --strict when CI is set, so these
two violations failed the workflow while local builds stayed green. The
callbacks that use these members are file scope functions rather than
members of the class, so private does not reach them; internal does, and
the class is internal already, so nothing is exposed that was not before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
libgit2 verifies the host key of an SSH remote against a known_hosts file
and fails the connection when it finds no match. iOS has no such file, so
every SSH remote failed with "invalid or unknown remote ssh hostkey" --
the whole SSH transport was broken, and nothing noticed because the tests
all talk to a repository on disk. The 0.28 inside the wrapper this branch
replaces did not verify host keys at all, so accepting them restores the
behaviour the app has always had.

The callback passes through for everything that is not an SSH host key, so
libgit2 keeps the verdict it reached for a TLS certificate and an
untrusted HTTPS remote is still refused.

Verifying host keys properly would mean storing them and asking the user
on first connect, which is worth doing but is a feature, not part of
swapping the backend.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every test so far cloned from a repository on disk, so libssh2, the TLS
stream of libgit2 and the credential callbacks that only remote operations
reach had no coverage at all. That is how a broken SSH transport reached
this branch unnoticed.

scripts/git_servers.sh starts an sshd and a git-http-backend behind TLS,
seeds a repository for each, and adds its certificate authority to the
trusted roots of the simulator, so the TLS of libgit2 is verified for real
rather than through a switch that turns verification off. The values the
tests need are written to .git-servers/env as TEST_RUNNER_ variables,
which xcodebuild passes into the test process. Both workflows start the
servers before the tests and stop them afterwards whatever the result.

Clone, pull and push are covered over both transports, along with a
refused password and a provider that gives up, which stands for the user
dismissing the passphrase prompt. Without the servers the tests skip and
the rest of the suite is unaffected.

Three things the servers must get right, each of which cost a round to
find: the certificate authority needs keyUsage or strict verifiers refuse
it while naming something else; the body of a push arrives chunked, so
reading only Content-Length hands git-http-backend an empty pack; and the
body has to be read before answering 401 or the retry that follows is
misparsed. The device is pinned in the test lane because the certificate
is trusted on one simulator.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Python accepted the certificate file in either order here, but the OpenSSL
on the runner did not: load_cert_chain wants the certificate first and the
key after it, so the HTTPS server exited at startup and only the timeout
was reported.

The wait now watches the process as well as the port, so a server that
dies is reported straight away together with whatever it printed, rather
than after twenty seconds of silence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
HTTPServer looks up the name of the host it bound to before it starts
accepting. Where that lookup is slow to answer, as it is on the runner,
the port is bound but not yet listening, so waiting for a listening socket
times out against a process that is perfectly healthy and silent.

The spike showed this and was read wrong: a plain TCP probe took 35
seconds to answer, which was put down to the handshake blocking a
single-threaded server.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A device name matches every runtime that offers it, so the certificate was
added to one simulator while the tests ran on another and every HTTPS test
failed with "untrusted connection error". The script now records the
identifier of the simulator it trusted and the test lane runs on exactly
that one.

The test for a refused password asserted only that something failed, so it
passed while HTTPS was not working at all. It now checks that the
connection itself succeeded, which is the whole point of it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adding a root certificate does not grant trust on every iOS version, and
the runner offers this device on runtimes from 18.0 to 26.1 while only
18.x was ever verified. Taking the first entry of a JSON object meant the
certificate was trusted on whichever simulator happened to be listed
first, and the HTTPS tests then failed with "untrusted connection error"
even though the device the tests ran on was pinned correctly.

The runtime is now chosen deliberately, preferring the major version known
to work, and is written to the log so a future failure says which one was
used. GIT_SERVERS_IOS_MAJOR overrides it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…device

simctl reports that it added the certificate authority to the trusted
roots of the simulator, and on the runner the trust does not take effect:
every HTTPS test failed with "untrusted connection error" while the device
the tests ran on was pinned correctly and the runtime was the same 18.x
that works here. Rather than keep guessing at that, the tests now accept
exactly the certificate of the server they talk to, which behaves the same
on every machine.

The pin lives behind DEBUG so a release build has no trace of it, and it
accepts one certificate rather than any: a new test points the pin at
something else and requires the connection to be refused. What is no
longer covered is the trust store itself, which those tests never really
covered anyway, since it only ever worked on one machine.

The assertions no longer expect the seeded commit to be the most recent
one. The push tests add to the same repositories, so that only held the
first time the suite ran against a fresh server; running it twice without
restarting the servers failed the second time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reading how many commits are unsynced walked the range and built a
GitCommit, with four heap strings, for every one of them, only to take
the count. Both configureTabBarItem and configureNavigationBar do that on
viewWillAppear, and the controller never stops observing, so one change
notification costs two walks per screen still on the stack. There is now
a counting walk that allocates nothing, and reset reports what it
discarded so its caller stops walking the same range a second time.

The date shown for a password came from a full blame, which reconstructs
the authorship of every line of the whole history, from inside a table
view delegate on the main thread. It now walks back to the first commit
whose content at that path differs from its parent's, which is what
git log -1 -- <path> answers, and stops there.

Renaming wrote the index twice, once for each side, in front of a commit
that works from the index in memory anyway. One handle, one write. The
signature was created and freed purely to validate it and then created
again to commit with, so validation is left to the commit, which reports
a name or email libgit2 will not take.

The certificate the transport tests accept travels with the options of the
operation rather than sitting in a global that a callback reads from
another thread. Nothing shared, and still compiled out of a release build.

The test for a cancelled prompt asserted only that the message was not
something else, so it would have passed on any unrelated failure; it names
the message now. Four tests cover the rewritten date, counting and reset.

The header of git_servers.sh still described trusting the authority on the
simulator, which was replaced by pinning two commits ago. It also killed
whatever held its ports, including processes it never started; it now
kills only what it recorded and refuses to start when a port is taken. A
CGI Status of 403 was answered as 200, delivering an error as if it were a
pack, a truncated chunk header killed the thread, and a checkout path with
a space produced a URL the tests could not parse.

The Intel simulator slice is built only on an Intel Mac. No runner can run
it, and it was a third of a cold build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant