Last updated Mar 14, 2024

Repository Hooks and Merge Checks Guide

Repository hooks and merge checks can be used to enforce policies on your commits and branches, or to trigger actions after a change has been made to a branch or a tag. Bitbucket's repository hooks are integrated with the git hook system, and allows plugins to validate and possibly reject pushes. In addition, repository hooks are called whenever an update to a branch or tag is made by Bitbucket Data Center. For instance, hooks are called to determine whether a pull request can be merged, when a branch is created from the browser or when a file is edited in the browser.

Hook types

There are three types of hooks: PreRepositoryHook, RepositoryMergeCheck and PostRepositoryHook. As the name implies, a PreRepositoryHook is called just before a change is made to one or more refs (branches and/or tags). The hook receives information about the proposed change, can then verify whether the changes should be allowed and choose to accept or reject the change. RepositoryMergeCheck is a specialization of PreRepositoryHook that is only called for pull request merges.

A PostRepositoryHook is invoked after the refs have been updated and receives the same information about the now completed change. The hook can use this information to perform further processing or notify an external system.

A basic example

Let's use a simple PostRepositoryHook to explore the hooks mechanism. The following hook simply logs what changes have been made.

1
2
/**
 * Example hook that logs what changes have been made to a set of refs
 */
public class LoggingPostRepositoryHook implements PostRepositoryHook<RepositoryHookRequest> {

    private static final Logger log = LoggerFactory.getLogger(LoggingPostRepositoryHook.class);

    @Override
    public void postUpdate(@Nonnull PostRepositoryHookContext context, 
                           @Nonnull RepositoryHookRequest hookRequest) {
        log.info("[{}] {} updated [{}]",
                hookRequest.getRepository(),
                hookRequest.getTrigger().getId(),
                hookRequest.getRefChanges().stream()
                    .map(change -> change.getRef().getId())
                    .collect(Collectors.joining(", ")));
    }
}

atlassian-plugin.xml fragment

1
2
<!-- Simple logging hook -->
<repository-hook key="logging-hook" name="Logging Post Hook"
                 i18n-name-key="hook.guide.logginghook.name"
                 class="com.atlassian.bitbucket.server.examples.LoggingPostRepositoryHook">
    <description key="hook.guide.logginghook.description" />
    <icon>icons/example.png</icon>
</repository-hook>

The example shows a PostRepositoryHook, which means that it only gets called after one or more refs have been updated. The hook logs the repository name, what triggered the change (e.g. 'push' or 'pull-request-merge') and the list of branches or tags that were updated. As can be seen from the example, all information about the change is available from the RepositoryHookRequest. The PostRepositoryHookContext provides the hook settings, if the hook is configurable.

Finally, the hook can register a callback with the context if it needs to inspect the commits that were added or removed as part of the change.

By defining the repository-hook element in atlassian-plugin.xml, the hook is added to Repository > Settings > Hooks, where it can be enabled.

Repository > Settings > Hooks

Here's the logging output from pushing a change to a single branch

1
2
2017-04-26 13:46:02,761 INFO  [AtlassianEvent::thread-2] admin @QJIZ9x826x475x0 1d63qb0 127.0.0.1 SSH - git-receive-pack '/bb/example.git' c.a.b.s.e.LoggingPostRepositoryHook [BB/example[1]] push updated [refs/heads/feature/PROJECT-1234-perform-magic-tricks]

Hook scopes

As of 5.2, there are two scopes a hook can be applied to: project and repository. Scopes determine which levels a hook can be enabled and configured at, although all hooks only fire for repository level events (ref changes and pull request merges). At the project level an admin can set a hook to enabled or disabled. However, at the repository level hooks can be set to enabled, disabled, or inherited. If a repository is set to inherited then it will automatically use the project's state (enabled or disabled) and configuration when the hook is run. (Note that in order to maintain backwards compatibility if a hook doesn't have any scopes defined it will fall back to repository level scoping.)

Let's continue with the example.

atlassian-plugin.xml fragment

1
2
<repository-hook key="logging-hook" name="Logging Post Hook"
                 i18n-name-key="hook.guide.logginghook.name"
                 class="com.atlassian.bitbucket.server.examples.LoggingPostRepositoryHook">
    <description key="hook.guide.logginghook.description" />
    <icon>icons/example.png</icon>
    <scopes>
        <scope>project</scope>
        <scope>repository</scope>
    </scopes>
</repository-hook>

We have updated the Logging Post Hook example to include scopes at both the project and repository level. The hook will work the same way it did before, but project admins will now be able to enable and configure the hook at the project level. Note that the project level configuration does not override the repository-level configuration, but instead allows the repository admin to inherit the configuration from the project if they wish to do so.

We can navigate to Project > Settings > Hooks and see the hook is configurable at the project level, but is initially disabled.

Repository > Settings > Hooks

Repository admins can also enable and configure the hook by going to Repository > Settings > Hooks. When a hook is first installed it is initially set to inherit the project's settings (effectively disabled). However, repository admins can choose to enable the hook at the repository level (and overwrite any project level configuration that had been set), or disable at the repository level (and ignore the project level setting).

Repository > Settings > Hooks

Note: If a hook is missing a particular scope it will still display on the hooks configuration page, but will be disabled. Here is the project settings page when the hook only has the repository scope.

Repository > Settings > Hooks

Choosing hook trigger(s)

LoggingPostRepositoryHook in the previous example implements PostRepositoryHook<RepositoryHookRequest>, which means that it gets invoked after one or more refs have been updated. Hooks can select when they should be called by choosing the type of hook request they respond to. The example hook is a hook of RepositoryHookRequest, which is the top-level request interface. As a result, the hook will be called for all triggers such as pushes, pull request merges, in-browser file edits, etc. If a hook is only interested in a specific trigger, the hook can pick the corresponding request type. For instance, a PostRepositoryHook<RepositoryPushHookRequest> will only be called for pushes. If the hook needs to be called for in multiple triggers, it should use the generic RepositoryHookRequest and check RepositoryHookRequest.getTrigger to decide whether to respond to the hook invocation or not.

The following triggers are supported (see StandardRepositoryHookTrigger):

TriggerCorresponding request typeDescription
BRANCH_CREATEBranchCreationHookRequestCalled when a branch is created from the browser or REST API
BRANCH_DELETEBranchDeletionHookRequestCalled when a branch is deleted from the browser or REST API
FILE_EDITFileEditHookRequestCalled when a file is edited or created in the browser or REST API
MERGEMergeHookRequestLow-level hook that is called when a branch is merged by the system, such as automatic branch merging. Most hooks should not attempt to block these types of requests.
PULL_REQUEST_MERGEPullRequestMergeHookRequestCalled to check whether a pull request can be merged (as a dryRun request) and when a pull request is actually merged
REPO_PUSHRepositoryPushHookRequestCalled when a client pushes changes to the repository
TAG_CREATETagCreationHookRequestCalled when a tag is created from the browser or REST API
TAG_DELETETagDeletionHookRequestCalled when a tag is deleted from the browser or REST API
UNKNOWN?Called when a change is detected for which the trigger is not known, as a result of a plugin raising a RepositoryRefsChangedEvent. There is no specific request type for this trigger. Instead, the generic RepositoryHookRequest is used.

Here's an example hook that only logs newly created tags:

1
2
/**
 * Hook that logs who created a new tag through the REST API
 */
public class TagCreationLoggingHook implements PostRepositoryHook<TagCreationHookRequest> {

    private static final Logger log = LoggerFactory.getLogger(TagCreationLoggingHook.class);

    private final AuthenticationContext authenticationContext;

    public TagCreationLoggingHook(AuthenticationContext authenticationContext) {
        this.authenticationContext = authenticationContext;
    }

    @Override
    public void postUpdate(@Nonnull PostRepositoryHookContext context,
                           @Nonnull TagCreationHookRequest request) {
        ApplicationUser user = authenticationContext.getCurrentUser();
        String username = user != null ? user.getName() : "<unknown>";
        Tag tag = request.getTag();
        log.info("[{}] {} created a new tag: {}, which references {}",
                request.getRepository(), username, tag.getDisplayId(), tag.getLatestCommit());
    }
}

TagCreationLoggingHook is a hook for TagCreationHookRequest and will only be called when a tag is created using the REST API. TagCreationHookRequest makes the newly created tag easily available through getTag().

Blocking changes

So far, the examples have been focused on PostRepositoryHook. Many hooks however enforce some policy and need to be able to prevent changes from being made. The following example shows a PreRepositoryHook that prevents the deletion of branches that have open pull requests.

1
2
public class BranchInReviewHook implements PreRepositoryHook<RepositoryHookRequest> {

    private final I18nService i18nService;
    private final PullRequestService pullRequestService;

    public BranchInReviewHook(I18nService i18nService, PullRequestService pullRequestService) {
        this.i18nService = i18nService;
        this.pullRequestService = pullRequestService;
    }

    @Nonnull
    @Override
    public RepositoryHookResult preUpdate(@Nonnull PreRepositoryHookContext context,
                                          @Nonnull RepositoryHookRequest request) {

        // Find all refs that are about to be deleted
        Set<String> deletedRefIds = request.getRefChanges().stream()
                .filter(refChange -> refChange.getType() == RefChangeType.DELETE)
                .map(refChange -> refChange.getRef().getId())
                .collect(Collectors.toSet());

        if (deletedRefIds.isEmpty()) {
            // nothing is going to be deleted, no problem
            return RepositoryHookResult.accepted();
        }

        // verify whether any of the refs are already in review
        PullRequestSearchRequest searchRequest = new PullRequestSearchRequest.Builder()
                .state(PullRequestState.OPEN)
                .fromRefIds(deletedRefIds)
                .fromRepositoryId(request.getRepository().getId())
                .build();

        Page<PullRequest> found = pullRequestService.search(searchRequest, PageUtils.newRequest(0, 1));
        if (found.getSize() > 0) {
            // found at least 1 open pull request from one of the refs that are about to be deleted
            PullRequest pullRequest = found.getValues().iterator().next();
            return RepositoryHookResult.rejected(
                    i18nService.getMessage("hook.guide.branchinreview.summary"),
                    i18nService.getMessage("hook.guide.branchinreview.details",
                            pullRequest.getFromRef().getDisplayId(), pullRequest.getId()));
        }

        return RepositoryHookResult.accepted();
    }
}

This hook demonstrates how a PreRepositoryHook can block a change from being made. Just like the PostRepositoryHook, the preUpdate method is called with the RepositoryHookRequest which provides information about the proposed change and a PreRepositoryHookContext which provides the optional settings. Where the postUpdate method just received information about the change, the preUpdate method needs to return a RepositoryHookResult to instruct the system to accept or reject the proposed change. If the hook rejects the change, the system uses the provided messages to provide feedback to the user, either in the browser or on the command line.

In the example, the helper method accepted() and rejected(String summary, String details) are used to construct the result. A hook can return multiple veto messages:

1
2
return new RepositoryHookResult.Builder()
        .veto("summary 1", "details 1")
        .veto("summary 2", "details 2")
        .build();

Here's the output for the BranchInReviewHook:

1
2
~/tmp/example (feature/PROJECT-1234-perform-magic-tricks ✔) ᐅ git push origin :feature/PROJECT-1234-perform-magic-tricks

remote: Branch is in review
remote: Offending branch: feature/PROJECT-1234-perform-magic-tricks is in review in pull request #5 and cannot be deleted
To ssh://bitbucket.dev.local:7999/bb/example.git
 ! [remote rejected] feature/PROJECT-1234-perform-magic-tricks (pre-receive hook declined)
error: failed to push some refs to 'ssh://git@bitbucket.dev.local:7999/bb/example.git'

Performance of preUpdate

Note that `PreRepositoryHooks` are called just before a change is made. This usually means that an operation, such as a push, is blocked while the hooks apply their checks. When writing hooks, it's important to consider the performance of these hooks as users will have to wait for the hook to complete its tests.

Validating commits

Many hooks not only need to validate the branches and tags that are updated but also need to validate the commits that are about to be added or removed. Examples are hooks that verify the committer or the commit message. Bitbucket's hook API allows hooks to register callbacks for these commit details. The system then determines which commits were added or removed for each updated branch and tag and notifies all registered callbacks.

Here's an example hook that logs a warning for each detected force-push:

1
2
/**
 * Hook that logs when a user performs a force-push
 */
public class ForcePushLoggingHook implements PostRepositoryHook<RepositoryPushHookRequest> {

    private static final Logger log = LoggerFactory.getLogger(ForcePushLoggingHook.class);

    @Override
    public void postUpdate(@Nonnull PostRepositoryHookContext context,
                           @Nonnull RepositoryPushHookRequest request) {

        // Only an UPDATE can be a force push, so ignore ADD and DELETE changes
        Map<String, RefChange> updates = request.getRefChanges().stream()
                .filter(refChange -> refChange.getType() == RefChangeType.UPDATE)
                .collect(Collectors.toMap(
                        refChange -> refChange.getRef().getId(),
                        refChange -> refChange));

        if (!updates.isEmpty()) {
            // register a callback to receive any commit that was removed from any ref. This hook is 
            // not interested in newly introduced commits, so it only registers for removed commits.
            context.registerCommitCallback(
                    new ForcePushDetectingCallback(request.getRepository(), updates),
                    RepositoryHookCommitFilter.REMOVED_FROM_ANY_REF);
        }
    }

    private static class ForcePushDetectingCallback implements RepositoryHookCommitCallback {

        private final Repository repository;
        private final Map<String, RefChange> refChangeById;

        private ForcePushDetectingCallback(Repository repository, 
                                           Map<String, RefChange> refChangeById) {
            this.repository = repository;
            this.refChangeById = refChangeById;
        }

    @Override
    public boolean onCommitRemoved(@Nonnull CommitRemovedDetails commitDetails) {
        // The callback may be called for a commit that was removed from the repository when a branch is 
        // deleted. Check whether the provided ref is updated before logging
        MinimalRef ref = commitDetails.getRef();
        // Remove the change because each change should be logged once, even if multiple commits were removed
        RefChange change = refChangeById.remove(ref.getId());
        if (change != null) {
            forcePushes.add(change);
        }
        // The hook only needs to receive more commits if there are RefChanges that have not yet been logged
        return !refChangeById.isEmpty();
    }

    // onEnd is called after the last commit has been provided
    @Override
    public void onEnd() {
        forcePushes.forEach(change ->
                log.warn("[{}] {} was force pushed from {} to {}", repository,
                        change.getRef().getDisplayId(), change.getFromHash(), change.getToHash()));
    }
}

The ForcePushLoggingHook registers a callback for REMOVED_FROM_ANY_REF commits. For each updated branch or tag, Bitbucket will determine which commits have been removed from the branch or tag and provide it to the registered callback. The CommitRemovedDetails specifies what commit (getCommit()) was removed from which ref (getRef()) and whether the commit was completely removed from the repository (isRemovedFromRepository()).

The onCommitRemoved method returns a boolean that indicates whether the callback wants to receive more commits, if available. Returning false when the hook is done allows Bitbucket to stop streaming commits early. RepositoryHookCommitCallback also provides a onCommitAdded(CommitAddedDetails commitDetails) method, but this example hook is not interested in added commits, so it does not implement the onCommitAdded method.

When registering a callback for commit details, one or more of the following filters can be provided:

FilterDescription
ADDED_TO_ANY_REFAny commit added to any ref. This includes commits that were newly added to the repository, but also commits that were already on another branch, but are now added to a branch or tag
ADDED_TO_REPOSITORYAny commit that is newly added to the repository
REMOVED_FROM_ANY_REFAny commit removed from any ref. This includes commits that were completely removed from the repository, but also commits that are still on another branch.
REMOVED_FROM_REPOSITORYAny commit that is completely removed from the repository

If a commit is added to or removed from multiple branches in a single change, the callback will receive the commit details multiple times; once for relevant branch.

PreReceiveHooks can also register callbacks to inspect commits and reject the change if they don't meet the hook's policy. As an example, here's a hook that rejects any push that contains commits that have the "Work in progress" as their commit message:

1
2
/**
 * Hook that blocks any newly introduced commits that have "Work in progress" in the commit message
 */
public class WorkInProgressHook implements PreRepositoryHook<RepositoryHookRequest> {
    @Nonnull
    @Override
    public RepositoryHookResult preUpdate(@Nonnull PreRepositoryHookContext context,
                                          @Nonnull RepositoryHookRequest request) {

        // hook only wants commits added to the repository
        context.registerCommitCallback(
                new WorkInProgressCallback(),
                RepositoryHookCommitFilter.ADDED_TO_REPOSITORY);

        // return accepted() here, the callback gets a chance to reject the change when getResult() is called
        return RepositoryHookResult.accepted();
    }

    private static class WorkInProgressCallback implements PreRepositoryHookCommitCallback {

        private RepositoryHookResult result = RepositoryHookResult.accepted();

        @Nonnull
        @Override
        public RepositoryHookResult getResult() {
            return result;
        }

        @Override
        public boolean onCommitAdded(@Nonnull CommitAddedDetails commitDetails) {
            Commit commit = commitDetails.getCommit();
            String message = commit.getMessage().toLowerCase();
            if (message.startsWith("work in progress")) {
                // use the i18nService to internationalize the messages for public plugins
                // (that are published to the marketplace)
                result = RepositoryHookResult.rejected(
                        "Don't push 'in progress' commits!",
                        "Offending commit " + commit.getId() + " on " + commitDetails.getRef().getDisplayId());
                // this will block the change, so no need to inspect further commits
                return false;
            }

            return true;
        }
    }
}

Global hooks

Hooks are listed on the Repository > Settings > Hooks page where they can be enabled or disabled (and configured) on a per-repository basis. However, some hooks need to be enabled for all repositories, without the option of disabling them. This can be achieved by adding the configurable="false" attribute to the repository-hook element in atlassian-plugin.xml:

1
2
<!-- Hook that logs all tags created through the REST API. This hook is marked configurable="false" to
     enable it globally. The hook won't be listed in Repository > Settings > Hooks and cannot be disabled -->
<repository-hook key="tag-creation-hook" name="Tag Creation Logging Hook" configurable="false"
                 class="com.atlassian.bitbucket.server.examples.TagCreationLoggingHook" />

Hooks marked as configurable="false" are enabled for all repositories and will not be listed on Repository > Settings > Hooks.

Merge checks

Merge checks are special hooks that only target pull request merges. There's a special RepositoryMergeCheck interface to save you some typing and merge checks should be registered as <repository-merge-check> in atlassian-plugin.xml but other than that, they work identically to the regular PreReceiveHook.

Registering merge checks as repository-merge-check allows Bitbucket to display all merge checks in the "Merge checks" section of the project and repository settings.

1
2
public class EnforceApprovalsMergeCheck implements RepositoryMergeCheck {

    /**
     * Vetoes a pull-request if there aren't enough approvals.
     */
    @Nonnull
    @Override
    public RepositoryHookResult preUpdate(@Nonnull PreRepositoryHookContext context,
                                          @Nonnull PullRequestMergeHookRequest request) {
        int requiredApprovals = context.getSettings().getInt("approvals", 0);
        int acceptedCount = 0;
        for (PullRequestParticipant reviewer : request.getPullRequest().getReviewers()) {
            acceptedCount = acceptedCount + (reviewer.isApproved() ? 1 : 0);
        }
        if (acceptedCount < requiredApprovals) {
            return RepositoryHookResult.rejected("Not enough approved reviewers", acceptedCount +
                    " reviewers have approved your pull request. You need " + requiredApprovals +
                    " (total) before you may merge.");
        }
        return RepositoryHookResult.accepted();
    }
}
1
2
<repository-merge-check key="enforce-approvals" name="Enforce Approvals"
                        class="com.atlassian.bitbucket.server.examples.EnforceApprovalsMergeCheck">
    <description>
       Enforces that pull requests must have a minimum number of acceptances before they can be merged.
    </description>
    <icon>icons/example.png</icon>
    <scopes>
        <scope>project</scope>
        <scope>repository</scope>
    </scopes>
    <config-form name="Simple Hook Config" key="simpleHook-config">
        <view>hook.guide.example.hook.simple.formContents</view>
        <directory location="/static/"/>
    </config-form>
    <!-- Validators can be declared separately -->
    <validator>com.atlassian.bitbucket.server.examples.ApprovalValidator</validator>
</repository-merge-check>

Adding configuration

It is possible to add configuration to your hook by specifying a configuration screen. The values of input elements defined in the screen will be saved by the framework and passed to the hook each time it is called. Generally we use AUI templates to generate our controls to keep styling the same, however a simple element <input name="x" type="text"/> would also work.

Hook configuration screens can be defined using Closure Templates. Atlassian is currently running an older version of Closure Templates, so some of Google's documentation may not be applicable.

Bitbucket Data Center provides bitbucket.component.branchSelector field and input templates that you can include to let users select a branch or tag.

Atlassian User Interface (AUI)

A number of AUI Soy templates are also available to assist in creating a configuration form for your hook. For more information about AUI please read the AUI Docs and try out the live demos in the sandbox. For an a list of available AUI Soy templates that you can use in your own configuration form, consult the AUI Soy source.

Here is the template for the EnforceApprovalsMergeCheck from the Merge Checks section:

1
2
{namespace hook.guide.example.hook.simple}

/**
 * @param config
 * @param? errors
 */
{template .formContents}
    {call aui.form.textField}
        {param id: 'approvals' /}
        {param value: $config['approvals'] /}
        {param labelContent}
            {getText('hook.guide.config.label')}
        {/param}
        {param description: getText('hook.guide.config.description') /}
        {param extraClasses: 'long' /}
        {param errorTexts: $errors ? $errors['approvals'] : null /}
    {/call}
{/template}

The form defines a single input field approvals. The configured value is available in the hook through context.getSettings().getInt("approvals", 0).

Validating hook configuration

The configuration that's entered in on the hook configuration dialog often needs to be validated before it's saved. This can be done by adding a <validator> element to the repository-hook or repository-merge-check in atlassian-plugin.xml. In the EnforceApprovalsMergeCheck, the following validator is used:

1
2
public class ApprovalValidator implements SettingsValidator {

    @Override
    public void validate(@Nonnull Settings settings, @Nonnull SettingsValidationErrors errors, @Nonnull Scope scope) {
        try {
            if (settings.getInt("approvals", 0) <= 0) {
                errors.addFieldError("approvals", "Number of approvals must be greater than zero");
            }
        } catch (NumberFormatException e) {
            errors.addFieldError("approvals", "Number of approvals must be a number");
        }
    }
}

Advanced Topics

Synchronous PostReceiveHooks

PostReceiveHook.postUpdate is usually invoked asynchronously a short time after the change has been completed. As a result, the postUpdate handling is non-blocking; the client does not have to wait for the hooks to complete their postUpdate processing. Sometimes, it is necessary to run synchronously. This is currently only supported for pushes by annotating the PostReceiveHook with @SynchronousPreferred. Such hooks will be invoked synchronously when possible (e.g. for pushes), and asynchronously when it is not.

Hooks that should only be invoked synchronously, should be annotated with @SynchronousPreferred(asyncSupported = false).

Writing to the git client output

RepositoryHookRequest provides access to the git client output and error streams through getScmHookDetails(). This will only return a value for pushes. For all other triggers, this will return empty().

1
2
/**
 * Hook that writes a warning to the git client when it detects a push to the master branch
 */
@SynchronousPreferred(asyncSupported = false)
public class PushToMasterWarnHook implements PostRepositoryHook<RepositoryPushHookRequest> {

    @Override
    public void postUpdate(@Nonnull PostRepositoryHookContext context,
                           @Nonnull RepositoryPushHookRequest request) {
        request.getScmHookDetails().ifPresent(scmDetails -> {
            request.getRefChanges().forEach(refChange -> {
                if ("refs/heads/master".equals(refChange.getRef().getId()) &&
                        refChange.getType() == RefChangeType.UPDATE) {
                    scmDetails.out().println("You should create a pull request and " +
                            "get some input from your team mates!");
                }
            });
        });
    }
}

This hook will only be invoked synchronously because it is marked @SynchronousPreferred(asyncSupported = false). Furthermore, it will only be called for pushes because it implements PostRepositoryHook<RepositoryPushEvent>.

Example output:

1
2
~/tmp/example (master ✔) ᐅ git push origin master
Counting objects: 3, done.
Delta compression using up to 8 threads.
Compressing objects: 100% (2/2), done.
Writing objects: 100% (3/3), 281 bytes | 0 bytes/s, done.
Total 3 (delta 1), reused 0 (delta 0)
remote: You should create a pull request and get some input from your team mates!
To ssh://bitbucket.dev.local:7999/bb/example.git
   d5730ac..191d0c4  master -> master

Rate this page: