Hooksman (Hooks Manager)

Pub Version

preview

Overview

The hooksman package allows you to manage and execute Git hooks using Dart. You can define your hooks as Dart files and register them with Git to run automatically when the corresponding events occur (e.g., pre-commit, post-commit, etc.). Inspired by lint-staged and husky, hooksman provides a flexible and powerful way to automate tasks during your workflow and share them across your team.

With hooksman you can run shell commands, Dart code, or a combination of both in your hooks to enforce coding standards, run tests, or perform other tasks.

Tasks are used to safeguard your codebase, if a task fails, hooksman exits with a non-zero status code, preventing the hook from completing (like a pre-commit hook).

Installation

Add hooksman to your pubspec.yaml:

dart pub add hooksman --dev

Then, run dart pub get to install the package.

Register Hooks

To register your hooks with Git, run the following command:

dart run hooksman

This command will compile your hooks and copy the executables to the hooks directory.

Warning

The hooksman package will overwrite all existing hooks in the .git/hooks directory with the new hooks. Make sure to back up any existing hooks before running the hooksman command.

Hooks

Create Hooks Directory

Create a hooks directory in the root of your project to store your hooks.

.
├── hooks
├── lib
│   └── ...
└── pubspec.yaml

Create Hook

Create your hooks as Dart files in the hooks directory. Each file should contain a main function that returns a Hook object, imported from the hooksman package.

import 'package:hooksman/hooksman.dart';

Hook main() {
    return Hook(
        ... // Tasks
    );
}

Note

hooksman scans the hooks directory for Dart files to use as hooks. You can organize your code by placing additional Dart files in subdirectories within the hooks directory. These files can be imported into your hook files and will not be picked up by hooksman as hooks.

.
└── hooks
├── tasks
│   ├── some_dart_task.dart # ignored
│   └── ...
└── pre_commit.dart # picked up

Hook Names

The name of the hook is derived from the file name. For example, a file named pre_commit.dart will be registered as the pre-commit hook. Be sure to follow the naming convention for Git hooks.

Tip

Look at the git hooks documentation for more information on the available hooks: Git Hooks Documentation.

Tasks

Tasks are modular units of work that you define to be executed during specific Git hook events. They allow you to automate checks, validations, or any custom scripts to ensure code quality and consistency across your repository. Tasks are powerful because they can be customized to suit your project's needs while targeting specific file paths or patterns.

All top level tasks are executed in parallel, while tasks within a group are executed sequentially. This allows you to run multiple tasks concurrently and group related tasks together.

File Patterns

You can specify file patterns to include or exclude from a task using any Pattern object (Glob, RegExp, String, etc.). Each task can have multiple include and exclude patterns.

Tip

hooksman exposes the Glob class from the Glob package to match file paths using glob patterns.

hooksman also has an AllFiles class to match all file paths.

Note

exclude filters any matching files before include is applied.

After the filters are applied, the remaining files are passed to the task's commands or run function.

Task Naming

Each task is assigned a name that is displayed when the task is executed. This is useful for identifying the task in the output. By default, the name of the task is the pattern(s) used to include files. If you would like to provide a custom name, you can do so by setting the name property of the task.

ShellTask(
    name: 'Analyze',
    include: [Glob('**.dart')],
    exclude: [Glob('**.g.dart')],
    commands: (filePaths) => [
        'dart analyze --fatal-infos ${filePaths.join(' ')}',
    ],
),

Shell Task

A ShellTask allows you to run shell commands.

ShellTask(
    name: 'Analyze',
    include: [Glob('**.dart')],
    exclude: [Glob('**.g.dart')],
    commands: (filePaths) => [
        'dart analyze --fatal-infos ${filePaths.join(' ')}',
    ],
),

Dart Task

A DartTask allows you to run Dart code.

DartTask(
    include: [Glob('**.dart')],
    run: (filePaths) async {
        print('Running custom task');

        return 0;
    },
),

Sequential Tasks

You can group tasks together using the SequentialTasks class, which runs the tasks sequentially, one after the other.

SequentialTasks(
    tasks: [
        ShellTask(
            include: [Glob('**.dart')],
            commands: (filePaths) => [
                'dart format ${filePaths.join(' ')}',
            ],
        ),
        ShellTask(
            include: [Glob('**.dart')],
            commands: (filePaths) => [
                'sip test --concurrent --bail',
            ],
        ),
    ],
),

Tip

Check out sip_cli for a Dart-based CLI tool to manage mono-repos, maintain project scripts, and run dart|flutter pub get across multiple packages.

Parallel Tasks

You can group tasks together using the ParallelTasks class, which runs the tasks in parallel.

ParallelTasks(
    tasks: [
        ShellTask(
            include: [Glob('**.dart')],
            commands: (filePaths) => [
                'dart format ${filePaths.join(' ')}',
            ],
        ),
        ShellTask(
            include: [Glob('**.dart')],
            commands: (filePaths) => [
                'sip test --concurrent --bail',
            ],
        ),
    ],
),

Tip

Check out sip_cli for a Dart-based CLI tool to manage mono-repos, maintain project scripts, and run dart|flutter pub get across multiple packages.

Predefined Tasks

ReRegisterHooks

It can be easy to forget to re-register the hooks with Git after making changes. Re-registering the hooks is necessary to ensure that the changes are applied, since your dart files are compiled into executables then copied to the .git/hooks directory.

To automate this process, you can use the ReRegisterHooks task. This task will re-register your hooks with Git whenever any hook files are created, modified, or deleted.

Hook main() {
  return Hook(
    tasks: [
      ReRegisterHooks(),
    ],
  );
}

Tip

If your hooks directory is not found in the root of your project, you can specify the path to the hooks directory to the ReRegisterHooks task.

ReRegisterHooks(pathToHooksDir: 'path/to/hooks'),

Hook Execution

The hooks will be executed automatically by Git when the corresponding events occur (e.g., pre-commit, post-commit, etc.).

Amending to the Commit

hooksman, similar to lint-staged, will add any created/deleted/modified files to the commit after running the tasks. Ensuring that the changes are included in the commit.

An example of this behavior is when you have a ShellTask that formats the code using dart format. If the code is not formatted correctly, hooksman will format the code and add the changes to the commit.

Partially Staged Files

A partially staged file is when you have some_file.dart staged in the index, along with some changes to the same some_file.dart file in the working directory.

When the tasks are executed, hooksman will stash any non-staged changes to partially staged files, run the tasks, then pop the stash with the changes. The is beneficial because it ensures that the tasks are run on the version of the file that is staged in the index, rather than the working directory.

Error Handling

If an error occurs during the execution of a task, hooksman will stop the execution of the remaining tasks and exit with a non-zero status code. This will prevent the commit from being made, allowing you to fix the issue before committing again.

Signal Interruption

If the user interrupts the hook execution (e.g., by pressing Ctrl+C), hooksman will stop the execution of the remaining tasks and exit with a non-zero status code. Extra precautions are taken to ensure that the repository is left in a clean state.

Configuration

Hooks are initially setup to run for the pre-commit hook, but you can configure the hooks to run for other events by specifying the diff and diffFilters parameters in the Hook object.

Diff Filters

The diffFilters parameter allows you to specify the statuses of files to include or exclude, such as added, modified, or deleted.


Hook main() {
  return Hook(
    diffFilters: 'AM', // Include added and modified files
    tasks: [
      ...
    ],
  );
}

Diff

The diff parameter allows you to specify how files are compared with the working directory, index, or commit.

The example below demonstrates how to compare files with the remote branch (e.g., origin/main). This could be useful for a pre-push hook.

Hook main() {
  return Hook(
    diffArgs: ['@{u}', 'HEAD'], // Compare files with the remote branch
    tasks: [
      ...
    ],
  );
}

Verbose Output

You can enable verbose output by using the VerboseHook class. This will slow down the execution of the tasks and output detailed information about the tasks being executed. This can be useful to understand the order of execution and the files being processed. This is not intended to be used in non-developing environments.

Example

// hooks/pre_commit.dart

import 'package:hooksman/hooksman.dart';

Hook main() {
  return Hook(
    tasks: [
      ReRegisterHooks(),
      ShellTask(
        name: 'Lint & Format',
        include: [Glob('**.dart')],
        exclude: [
          Glob('**.g.dart'),
        ],
        commands: (filePaths) => [
          'dart analyze --fatal-infos ${filePaths.join(' ')}',
          'dart format ${filePaths.join(' ')}',
        ],
      ),
      ShellTask(
        name: 'Build Runner',
        include: [Glob('lib/models/**.dart')],
        exclude: [Glob('**.g.dart')],
        commands: (filePaths) => [
          'sip run build_runner build',
        ],
      ),
      ShellTask(
        name: 'Tests',
        include: [Glob('**.dart')],
        exclude: [Glob('hooks/**')],
        commands: (filePaths) => [
          'sip test --concurrent --bail',
        ],
      ),
    ],
  );
}

License

This project is licensed under the MIT License.