Skip to content

Support prepare-commit-msg hooks - #2795

Open
DCVortexxx wants to merge 1 commit into
git-up:masterfrom
DCVortexxx:prepare-commit-msg-hook
Open

Support prepare-commit-msg hooks#2795
DCVortexxx wants to merge 1 commit into
git-up:masterfrom
DCVortexxx:prepare-commit-msg-hook

Conversation

@DCVortexxx

@DCVortexxx DCVortexxx commented Jul 7, 2026

Copy link
Copy Markdown

Disclosure

I AGREE TO THE GITUP CONTRIBUTOR LICENSE AGREEMENT.

Full disclosure, this has been drafted by AI because I haven't played with ObjC for yeeeeeaaaaars, but I believe that it respects the coding standard and contribution rules.

Of course, I'm open to suggestions and will happily update it to make it into master.
I LOVE GitUp and having to rely on another editor just because a project I work on needs this hook on it is a pain 😅

Summary

This adds support for Git's prepare-commit-msg hook when creating commits from GitUp.

Git documents prepare-commit-msg as running after the default commit message is prepared and before the editor starts.

To match that lifecycle, GitUp now runs the hook when the commit view prepares the message editor in viewWillAppear :

  • The hook runs asynchronously so the commit view remains editable:
    • If the user starts typing before the hook finishes, GitUp preserves their input and inserts the prepared hook output before the user's content.
    • If the hook is still running when the user tries to commit, GitUp prevents the commit and asks the user to wait, since an installed prepare-commit-msg hook should be allowed to finish.

It also runs when the user toggles Amend:

  • GitUp first seeds the editor with the HEAD commit message, as it already did before this change.
  • It then reruns prepare-commit-msg with Git's amend-style commit source
    • If the message currently shown was only auto-filled by the first prepare-commit-msg run, GitUp replaces it with the amend message flow.
    • If the user has already typed their own content, GitUp preserves it instead of overwriting it.

This also has a side effect on the commit-msg hook, because it used to rely on a app-owned, temporary file for writing the commit message, instead the the $GIT_DIR/COMMIT_EDITMSG file Git uses.
Since prepare-commit-msg relies on this file, commit-msg should read it as well, as the single source of truth for commit message edition.

Note

My first implementation ran it when the user actually commits, right before commit-msg, and it made the code significantly simpler, since we don't need to handle in-flight edit, and "just" update the message in the same way commit-msg does.

However, I changed it to be closer to Git's semantics.
Feel free to let me know if you feel like this is a tradeoff worth having.

Run prepare-commit-msg when GitUp prepares the commit message editor so hook output appears before the user submits the commit.

Keep commit-msg validation at commit time using Git's standard COMMIT_EDITMSG file, and preserve user edits if the async prepare hook finishes after typing begins.
@lucasderraugh

Copy link
Copy Markdown
Collaborator

@DCVortexxx Thank you for the candor in using AI. It gives me the appropriate expectation. I assume you've tested locally?

It sounds like a good addition and I will look at the changes soon. This Thursday I'll likely be releasing the 1.5.0 release of GitUp to the stable channel so it won't go in before that.

Probably next week is a realistic time for me to start testing out this change.

@DCVortexxx

DCVortexxx commented Jul 9, 2026

Copy link
Copy Markdown
Author

Hey @lucasderraugh, thanks for getting back to me.

Yeah, I tested it locally, through all paths that came to mind :

  • Happy path
    • hook runs quickly and updates the commit message when you open the commit view.
  • Amend commit
    • hook re-runs with commit argument and the amended commit SHA
  • Long hook
    • commit is prevented while the hook is still running.
    • prepared message is inserted above the user input if there were modifications
  • Failed hook
    • Error is shown as soon as the hook fails
    • Commit is prevented
      • I wondered if we should allow the user to commit anyway here.
      • The error was already shown once on hook failure, so if the user actually wants to commit, that's up to him.
      • Especially because re-running the hook requires closing and re-opening the commit view, which is probably not that intuitive.
      • Change is trivial, I'll let this product decision up to you
  • Multiple hooks:
    • prepare-commit-msg, pre-commit, commit-msg, and post-commit still works
    • They are executed in the correct order
  • Merge commit :
    • Started outside GitUp
      • All hooks work correctly, including prepare-commit-msg
    • Started with GitUp GUI
      • The merge message input is generated through a dedicated popup, without going through the commit view.
      • In that scenario, no hooks are executed at all, including prepare-commit-msg.
      • IMO, that's a dedicated fix / feature, not in the scope of this PR

I also have validated that the implementation is reasonable, according to my knowledge of the codebase of course.

Let me know if I can help in any way, and of course, no worries about the timing,
Max

@lucasderraugh

Copy link
Copy Markdown
Collaborator

Wonderful, thank you!

@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 support for Git's prepare-commit-msg hook in GICommitViewController, running it asynchronously when the commit view appears and when amend mode is toggled. It also changes the commit-msg hook from using a temporary file to $GIT_DIR/COMMIT_EDITMSG, aligning with Git's own behavior.

  • The async hook execution uses a generation counter for cancellation, correctly preventing stale results from applying after navigation or amend toggling.
  • Two issues with error handling: when the hook fails, the error is surfaced both eagerly (in the async completion block) and again lazily (when the user clicks Commit), causing a duplicate alert; and _prepareCommitMessageHookError is never cleared after the commit attempt blocks, leaving the user with no apparent way to retry without navigating away.
  • _prepareCommitMessageHookArgumentsWithMessagePath:amendCommitSHA1: omits the squash and template source types defined by Git, which may cause hooks that branch on source type to behave incorrectly.

Confidence Score: 3/5

The hook execution logic and cancellation mechanism are structurally sound, but the error-handling path leaves users in an unrecoverable state after a hook failure without a clear way to proceed.

When prepare-commit-msg fails, the error dialog fires immediately in the async completion block and again when the user clicks Commit, so they see the same alert twice. After that second alert, _prepareCommitMessageHookError remains set and every subsequent commit attempt also fails with no in-UI recovery action. Users must discover on their own that navigating away clears the state.

Files Needing Attention: GitUpKit/Views/GICommitViewController.m — specifically the async completion block and the createCommitFromHEADWithMessage: error-guard section.

Important Files Changed

Filename Overview
GitUpKit/Views/GICommitViewController.m Adds async prepare-commit-msg hook execution with generation-based cancellation and COMMIT_EDITMSG-based commit-msg hook path; two issues around double error presentation and a sticky unrecoverable error state block users from committing after a hook failure.

Sequence Diagram

sequenceDiagram
    participant UI as GICommitViewController (main)
    participant BG as Background Queue
    participant FS as COMMIT_EDITMSG

    UI->>UI: viewWillAppear (message empty)
    UI->>UI: ++generation
    UI->>BG: dispatch_async (global queue)
    BG->>FS: writeToFile: initialMessage
    BG->>BG: runHookWithName: prepare-commit-msg
    BG->>FS: hook may modify COMMIT_EDITMSG
    BG->>FS: read back COMMIT_EDITMSG
    BG->>UI: dispatch_async (main queue)
    alt generation matches
        alt hook succeeded
            UI->>UI: _applyPreparedCommitMessage:
        else hook failed
            UI->>UI: presentError (first display)
        end
    else generation mismatch
        UI->>UI: discard result
    end
    UI->>UI: user clicks Commit
    alt hook still running
        UI->>UI: alert: still running
    else hook error stored
        UI->>UI: presentError (duplicate display)
    else
        UI->>UI: pre-commit hook
        UI->>FS: write message for commit-msg
        UI->>UI: commit-msg hook
        UI->>FS: read back message
        UI->>UI: performCommitCreation
        UI->>UI: post-commit hook
        UI->>UI: didCreateCommit:
    end
Loading

Reviews (1): Last reviewed commit: "Support prepare-commit-msg hooks" | Re-trigger Greptile

Comment on lines +184 to +194
dispatch_async(dispatch_get_main_queue(), ^{
if (generation != _prepareCommitMessageHookGeneration) {
return;
}
_prepareCommitMessageHookRunning = NO;
_prepareCommitMessageHookError = error;
if (preparedMessage) {
[self _applyPreparedCommitMessage:preparedMessage replacingInitialMessage:initialMessage];
} else if (error) {
[self presentError:error];
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Hook failure shows the error twice

When prepare-commit-msg fails, [self presentError:error] is called here in the async completion block, and _prepareCommitMessageHookError is also stored. If the user dismisses that alert and then clicks Commit, createCommitFromHEADWithMessage: checks _prepareCommitMessageHookError and calls [self presentError:_prepareCommitMessageHookError] a second time. The user sees the identical error dialog twice with no indication of how to recover other than navigating away and back.

Consider showing the error only in one place: either eagerly (here, and not re-checking in createCommitFromHEADWithMessage:), or lazily (only in createCommitFromHEADWithMessage:, silently storing the failure). The current code does both.

Comment on lines +278 to +285
if (_prepareCommitMessageHookRunning) {
[self presentAlertWithType:kGIAlertType_Caution title:NSLocalizedString(@"The prepare-commit-msg hook is still running", nil) message:NSLocalizedString(@"Please wait for it to finish before committing.", nil)];
return;
}
if (_prepareCommitMessageHookError) {
[self presentError:_prepareCommitMessageHookError];
return;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 _prepareCommitMessageHookError never cleared on failed commit attempt

After a hook failure the user is blocked from committing, but createCommitFromHEADWithMessage: shows the error and returns without clearing _prepareCommitMessageHookError. Every subsequent commit attempt will show the same error and abort. The only implicit recovery paths are toggling Amend (the else branch calls _cancelPrepareCommitMessageHook) or navigating away (which calls viewDidDisappear). Neither path is surfaced to the user in the error message, so they are left with no visible way forward.

At minimum, the error should be cleared after being presented here, or the alert should include a recovery suggestion (e.g., a "Retry" action that re-runs the hook).

Comment on lines +176 to +196
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
NSError* error = nil;
NSString* preparedMessage = nil;
GCRepository* repository = [[GCRepository alloc] initWithExistingLocalRepository:repositoryPath error:&error];
if (repository && [initialMessage writeToFile:messagePath atomically:YES encoding:NSUTF8StringEncoding error:&error] && [repository runHookWithName:@"prepare-commit-msg" arguments:arguments standardInput:nil error:&error]) {
preparedMessage = [[NSString alloc] initWithContentsOfFile:messagePath encoding:NSUTF8StringEncoding error:&error];
}

dispatch_async(dispatch_get_main_queue(), ^{
if (generation != _prepareCommitMessageHookGeneration) {
return;
}
_prepareCommitMessageHookRunning = NO;
_prepareCommitMessageHookError = error;
if (preparedMessage) {
[self _applyPreparedCommitMessage:preparedMessage replacingInitialMessage:initialMessage];
} else if (error) {
[self presentError:error];
}
});
});

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 Strong capture of self in nested dispatch blocks

The outer dispatch_async block accesses _prepareCommitMessageHookGeneration and _prepareCommitMessageHookRunning via implicit self->_ivar syntax, capturing self strongly. The inner dispatch_get_main_queue block does the same. This is safe against crashes (the generation check guards stale application), but it extends the view controller's lifetime until the background task finishes, even after the view has been dismissed and ownership released. The idiomatic ObjC pattern is to capture a __weak reference, then create a __strong local at the top of the inner block and bail early if it is nil.

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!

Comment on lines +110 to +118
- (NSArray*)_prepareCommitMessageHookArgumentsWithMessagePath:(NSString*)path amendCommitSHA1:(NSString*)sha1 {
if (_amendButton.state) {
return sha1 ? @[ path, @"commit", sha1 ] : @[ path, @"commit" ];
}
if (self.repository.state == kGCRepositoryState_Merge) {
return @[ path, @"merge" ];
}
return @[ path ];
}

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 Missing squash and template source types for prepare-commit-msg

Git defines five source types for prepare-commit-msg: message (from -m/-F), template, merge, squash, and commit (amend). This implementation only handles merge, amend (commit), and the default (no source). Squash commits — which Git generates during interactive rebase — are a common use case for prepare-commit-msg hooks. If GitUp drives squash commits, those paths will invoke the hook with only the path argument instead of path squash, producing incorrect hook behavior for hooks that branch on the source type.

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