Skip to content

Add SSH commit signing for HEAD commits - #2789

Open
kausters wants to merge 5 commits into
git-up:masterfrom
kausters:ssh-signed-commits
Open

Add SSH commit signing for HEAD commits#2789
kausters wants to merge 5 commits into
git-up:masterfrom
kausters:ssh-signed-commits

Conversation

@kausters

@kausters kausters commented Jun 14, 2026

Copy link
Copy Markdown

Summary

Hi! This PR adds SSH commit signing support for GitUpKit user-facing HEAD commit flows. Repositories configured like the Git CLI with commit.gpgsign=true and gpg.format=ssh now create commits with a gpgsig SSH signature for normal commits, merge commits, amend, and conflict-resolution commits.

To limit changes scope, the change intentionally supports only SSH signing. Non-SSH signing formats, including OpenPGP and X.509, continue to produce unsigned GitUp commits rather than blocking existing workflows. History replay/reorder paths also remain unsigned; signing those GitUp-created rewrite commits can be considered separately from signing commits users make directly.

What changed

  • Adds a private GCCommitSigning.m helper that creates the unsigned commit buffer, signs it with SSH, and writes the signed object through libgit2.
  • Resolves SSH signing config from user.signingkey, inline SSH public keys, and gpg.ssh.defaultKeyCommand.
  • Uses gpg.ssh.program or ssh-keygen, launched with GitUp login-shell PATH handling so GUI-launched GitUp can find shell-installed signers.
  • Routes user-facing HEAD commit creation through the signing-aware helper while leaving replay/rewrite helpers unchanged.
  • Adds focused signing tests plus HEAD integration coverage for signed normal, merge, and amended commits.

Tests

  • Focused GitUpKit signing and HEAD integration tests pass.
  • Test coverage includes unsigned behavior when signing is disabled, unsigned behavior for unsupported non-SSH formats, missing key errors, inline key and default key command resolution, key path signing, signer failure, and Git CLI verify-commit verification with an allowed signers file.

Notes

  • I AGREE TO THE GITUP CONTRIBUTOR LICENSE AGREEMENT
  • I am not an Obj-C or even MacOS developer at all so if anything's not up to the project standards, please let me know
  • I'm not clear on the wishes of the contribution guidelines around commits, as I normally just have GitHub squash PR commits on merge. Please let me know if you'd like them pre-flattened or whatever.
  • Fixes (partially? because we're not signing all commits yet, might follow up with another PR) Support SSH signing #1036.
  • Note how all the commits in this PR show up as "Verified" on GitHub 😉 Some repos actually require that, hence my doing this.

@lucasderraugh

Copy link
Copy Markdown
Collaborator

Thanks for the contribution @kausters. I'm out for some time so I probably won't review it this week. I'm also looking to push out the revamped release I've had in the continuous channel for quite some time to stable. So ballpark 2+ weeks from now we'll get this in a build if all looks good.

@lucasderraugh
lucasderraugh self-requested a review June 14, 2026 23:06
@lucasderraugh

Copy link
Copy Markdown
Collaborator

@greptile Can you review this PR?

@greptile-apps

greptile-apps Bot commented Jul 27, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds SSH commit signing to GitUp's three user-facing HEAD commit flows (create, merge, amend) by introducing a new GCCommitSigning.m helper that uses the libgit2 git_commit_create_buffer → sign → git_commit_create_with_signature pattern. Non-SSH signing formats (OpenPGP, X.509) and history-replay/rebase paths remain unsigned by design.

  • Core signing flow: GCCreateCommitFromTreeWithOptionalSignature reads commit.gpgsign and gpg.format, builds the unsigned commit buffer, pipes it to ssh-keygen (or gpg.ssh.program) via GCTask, and stores the signed object through libgit2. Key resolution handles user.signingkey paths, inline public keys (written to a temp file and cleaned up), and gpg.ssh.defaultKeyCommand output.
  • HEAD routing: All three GCRepository+HEAD commit methods now delegate to the signing-aware helpers; the existing GCRepository+Bare replay/rewrite methods are deliberately left unchanged.
  • Tests: New unit tests cover unsigned/unsupported-format pass-through, missing key errors, inline key, defaultKeyCommand, key-path signing, signer failure, and Git CLI verify-commit verification.

Confidence Score: 4/5

The signing path is additive and opt-in; repositories without commit.gpgsign=true are completely unaffected. The libgit2 buffer/sign/write flow is implemented correctly, and all three HEAD commit operations are wired through the new helper.

The core signing logic is sound and the test coverage is solid, including an end-to-end verify-commit check with a real SSH key. The two areas worth a second look are the silent unsigned-commit fallback for OpenPGP/X.509 users and the un-synchronized process-global PATH cache, neither of which corrupts data or breaks existing unsigned workflows.

Files Needing Attention: GCCommitSigning.m deserves a close read — specifically the _CommitSigningPATH caching strategy and the silent fall-through in _ShouldSSHSignCommit for non-SSH formats. The two test files also duplicate helper code that would be worth consolidating.

Important Files Changed

Filename Overview
GitUpKit/Core/GCCommitSigning.m New file implementing SSH commit signing. The libgit2 buffer/sign/create flow is correct, but _CommitSigningPATH uses an un-synchronized process-global static cache that is never invalidated.
GitUpKit/Core/GCRepository+HEAD.m Routes all three user-facing HEAD commit operations (create, merge, amend) through the new signing-aware helpers. Parents array and count logic is preserved correctly.
GitUpKit/Core/GCCommitSigning-Tests.m Good coverage of signing scenarios including disabled, unsupported format, inline key, defaultKeyCommand, path key, and signer failure. Duplicates _CommitSignature and setup helpers that also appear in the HEAD test file.
GitUpKit/Core/GCRepository+HEAD-Tests.m Adds integration test that verifies normal, merge, and amend commits are all SSH-signed and verifiable by the Git CLI. Contains duplicated _CommitSignature and _ConfigureSSHSigningWithKeyPath helpers.
GitUpKit/Core/GCPrivate.h Adds declarations for the two new public signing helpers and the getPATHUsingShell: method. Changes are minimal and correct.
GitUpKit/GitUpKit.xcodeproj/project.pbxproj Adds GCCommitSigning.m and GCCommitSigning-Tests.m to all three relevant build targets (framework, unit test, and iOS framework).

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["User triggers commit\n(create / merge / amend)"] --> B["createCommitFromHEAD…\n(GCRepository+HEAD.m)"]
    B --> C["_CreateCommitFromIndex /\nGCCreateCommitFromCommitWithIndexAndOptionalSignature"]
    C --> D["GCCreateCommitFromTreeWithOptionalSignature"]
    D --> E{"_ShouldSSHSignCommit"}
    E -- "commit.gpgsign=false\nor gpg.format ≠ ssh" --> F["git_commit_create\n(unsigned)"]
    E -- "gpg.format = ssh" --> G["git_commit_create_buffer\n(build commit content)"]
    G --> H["_SSHSignatureForCommitBuffer"]
    H --> I["_SSHSigningKeyPath\nresolve key from user.signingkey\nor gpg.ssh.defaultKeyCommand"]
    I --> J["_CommitSigningPATH\nresolve shell PATH (cached)"]
    J --> K["/usr/bin/env gpg.ssh.program\nor ssh-keygen -Y sign -n git -f key"]
    K -- "non-zero exit" --> L["Error: signer failed"]
    K -- "signature on stdout" --> M["git_commit_create_with_signature\n(write signed object)"]
    M --> N["git_commit_lookup → GCCommit"]
    F --> N
    N --> O["Update HEAD reference\n(reflog)"]
Loading

Comments Outside Diff (1)

  1. GitUpKit/Core/GCCommitSigning.m, line 317-325 (link)

    P2 Process-global PATH cache is never invalidated

    cachedPATH is a static local that is populated on the first signing call and then reused for the entire process lifetime — across all repositories, users, and sessions in the same GitUp process. Two consequences: (1) if the first PATH resolution succeeds via the fallback /bin/sh but the primary $SHELL was temporarily unavailable, every subsequent signing will silently use the narrower /bin/sh PATH even after the shell recovers; (2) in tests, if one test initialises the cache with a stripped PATH, later tests inherit it.

    This is the same one-shot caching pattern used by runHookWithName: in GCRepository.m, so it is consistent with the existing codebase. Still worth flagging because signing is more security-sensitive than hook execution: an unexpected tool being resolved from a cached PATH could silently produce commits signed by the wrong key program.

Reviews (1): Last reviewed commit: "Hide amend parent handling in signing he..." | Re-trigger Greptile

Comment on lines +29 to +38
}
NSString* signature = [[NSString alloc] initWithBytes:buffer.ptr length:buffer.size encoding:NSUTF8StringEncoding];
git_buf_free(&buffer);
return signature;
}

static BOOL _WriteLocalConfigOption(GCRepository* repository, NSString* variable, NSString* value) {
return [repository writeConfigOptionForLevel:kGCConfigLevel_Local variable:variable withValue:value error:NULL];
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Duplicated test helpers across test files

_CommitSignature() is copy-pasted character-for-character into GCRepository+HEAD-Tests.m (lines 581–591 of that file). _ConfigureSSHSigningWithKeyPath also appears in both files, with slightly different implementations (one uses a _WriteLocalConfigOption wrapper, the other calls writeConfigOptionForLevel:variable:withValue:error: directly). If either helper needs to change — for example, to read a different signature header field for a future signing format — the two copies will diverge silently.

Both helpers should be extracted into GCTestCase (or a dedicated signing test utility category) so they are shared by both test files.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@kausters kausters Jul 27, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@lucasderraugh not sure what the project convention is for shared test helpers across files, happy to adjust or leave it as-is, up to you

@lucasderraugh lucasderraugh Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I think reusing this same call would be ideal. A small file with helpers in the test target would be fine.

Comment on lines +295 to +315
CALL_LIBGIT2_FUNCTION_GOTO(cleanup, git_tree_lookup, &tree, repository.private, &oid);

parentCount = git_commit_parentcount(amendedCommit);
if (parentCount) {
parentCommits = (git_commit**)calloc(parentCount, sizeof(git_commit*));
if (!parentCommits) {
GC_SET_GENERIC_ERROR(@"Unable to allocate commit parent list");
goto cleanup;
}
for (unsigned int i = 0; i < parentCount; ++i) {
CALL_LIBGIT2_FUNCTION_GOTO(cleanup, git_commit_parent, &parentCommits[i], amendedCommit, i);
}
}
parents = (const git_commit**)parentCommits;

commit = GCCreateCommitFromTreeWithOptionalSignature(repository, tree, parents, parentCount, git_commit_author(amendedCommit), message, error);

cleanup:
if (parentCommits) {
for (unsigned int i = 0; i < parentCount; ++i) {
git_commit_free(parentCommits[i]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Silent unsigned fallback when commit.gpgsign=true but format is not SSH

When gpg.format is set to openpgp or x509 (or any non-SSH value), _ShouldSSHSignCommit sets *shouldSign = NO and returns YES, causing GCCreateCommitFromTreeWithOptionalSignature to silently produce an unsigned commit. The test testCommitSigningLeavesCommitsUnsignedWhenDisabledOrUnsupported documents this behaviour, and the PR description explicitly acknowledges the tradeoff.

The concern is that a repository administrator who enforces commit.gpgsign=true with OpenPGP will silently receive unsigned commits from GitUp with no warning. Would it be worth surfacing a non-fatal console log (XLOG_WARNING) so that developers using GitUp in such repositories at least see a diagnostic message, even if the commit still succeeds?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this can be ignored.

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.

2 participants