Per-workspace pre-commit checks with Lefthook
This lesson preview is part of the Bundling and Automation in Monorepos course and can be unlocked immediately with a \newline Pro subscription or a single-time purchase. Already have access to this course? Log in here.
Get unlimited access to Bundling and Automation in Monorepos, plus 90+ \newline books, guides and courses with the \newline Pro subscription.

[00:00 - 00:15] In this lesson, we're going to look at adding per-workspace pre-commit checks. Right now we have shown how we can apply pre-commit checks to the whole Monorepo, but sometimes a workspace within a Monorepo will have its own specific requirements.
[00:16 - 00:35] So let's just dive in. I'm going to open lefthook.yaml, and I'm going to start adding a new configuration that's going to execute the TypeScript type checker within our Next.js workspace. I'm going to name our new command "apps/frontend-nextjs:tsc".
[00:36 - 00:50] It's going to have a "root" of "apps/frontend-nextjs", and the command it's going to run is going to be "pnpm tsc --no-emit". I'm then going to go back to our command line.
[00:51 - 01:09] I'm going to add my changes and then I'm going to run "pnpm lefthook run pre-commit". We're going to see that our apps/frontend-nextjs:tsc command is skipped because there are no matching changed files staged for this commit.
[01:10 - 01:30] So to force this to execute I'm going to open apps/frontend-nextjs/nextconfig.js, and I'm just going to change the file by adding a comment. Then I will close out of that file, "git add" my changes, and then run pnpm pre-commit.
[01:31 - 01:51] Once again, if we look at the Lefthook output right now, we're going to see that it executed successfully - with "tsc" there is no textual output when everything works. Let me go back to our lefthook.yaml file, and this is the basics of having a check that is workspace specific.
[01:52 - 02:07] You define your check as normal. You set the "root" folder to be the workspace folder, and the check is only going to execute if there are files that were changed for this commit that match that specific folder.
[02:08 - 02:19] We must consider that this is a little naive in terms of testing. If our workspace was depending on other code and that code changed, this check would not run.
[02:20 - 02:32] However, this is fine for local pre-commit checks, and we're going to use a more sophisticated check when we run our full CI. And that's it. Let's look at what we can do to make this even better.
[02:33 - 03:04] First we're going to set it up so it only executes on files that TypeScript can actually check, which we're going to do by setting the "glob" option and setting all the extensions that TypeScript supports. And because I want to apply the TypeScript check to other workspaces, I'm going to pull out the shared part of it into a new YAML hash that I'm going to call "__shared_commands__". Inside, I'm going to define a key called "tsc" and add a reference with "&tsc".
[03:05 - 03:14] And I'm going to copy over my "glob" and "run" options. Then I'll go down to my "apps/frontend-nextjs:tsc" command.
[03:15 - 03:30] I'm going to delete the common stuff and then I'm going to extend from my "&tsc" identifier. I'm going to go down to the command line, add my changes and try running Lefthook again. And it fails because I got something wrong.
[03:31 - 03:41] Ah, yes. When you define an identifier you use "&", but when you extend from it, you use "*". So this needs to be "*tsc".
[03:42 - 04:00] I'm going to go down to the command line, add my changes again, run "lefthook pre-commit", and then we're going to see that now it works. Let me go back to the file and explain what we just did. The __shared_commands__ hash is not something specific to Lefthook.
[04:01 - 04:44] This is functionality that exists in all YAML parsers. You can define hashes and then reference values from those hashes and extend from them. So we're just using existing YAML functionality to make this a little bit less repetitious. Now that we have the building blocks in place, I'm just going to collapse this into a single line and then add the other two workspaces, frontend-vite and server-express by copying our initial line and just changing our root folder. Then I'm going to go down to the command line, run this again and we can see that it runs correctly, although it skips these two workspaces because there are no changed files in them.
[04:45 - 04:57] I'm going to go into our server-express workspace. I'm going to do a small change there and then add my changes and we should be able to see it run for that workspace.
[04:58 - 05:18] And it does. So this is how we can do TypeScript execution per workspace with a shared configuration. I'm going to use the same pattern and create an ESLint shared configuration and then apply that to our three workspaces. It is again going to use a named identifier called "&lint".
[05:19 - 05:51] It's going to use the exact same glob. And the "run" command is going to be somewhat complicated. We're going to run "pnpm eslint --max-warnings-0 --report-unused-disable-directives --no-warn-ignored" and then "{staged_files}". The important options here are "--no-warn-ignored", which means if there is a specific file that's ignored in ESLint but we pass it in here, ESLint is not going to print any warnings.
[05:52 - 06:05] The "{staged_files}" variable is going to get automatically replaced by Lefthook. Now I'll copy over my TSC commands.
[06:06 - 06:10] I'm going to replace TSC with lint. Then I'm going to go down to the command line.
[06:11 - 06:22] Add my changes, run "pnpm lefthook pre-commit" oops. And I have a typo. This should be "staged_files" rather than "staged_viles".
[06:23 - 06:30] The pre-commit check fails now. Let's run it on its own.
[06:31 - 06:56] It complains about the "eslint" command within lefthook.yaml not being defined as a dependency in the root of the monorepo. And indeed, if we open our package.json, we're going to see that we have TypeScript as a root dependency in our Monorepo, which makes it okay to have "tsc" in Lefthook, but we don't have ESLint in the root of our monorepo.
[06:57 - 07:18] In this case, I am happy to just disable Knip checking Lefthook, because Knip doesn't know in what context a given command is going to be executed. In this case, we execute the ESLint command not from the root but from the respective workspaces, which is fine.
[07:19 - 07:36] So to fix this, I'm just going to set lefthook.yaml as an ignored file in our knip.json. Then I'm going to add my changes, run knip again, see that it works fine, and then run our pre-commit check. It now passes successfully.
[07:37 - 07:46] The last set of checks I want to add is for running Jest and Vitest tests. First I'm going to add jest.
[07:47 - 08:16] It's again going to be using the same glob pattern and the command is going to be "pnpm jest --run-in-band", which means only use one runner, "--findRelatedTests", which means if only a project file is changed, try to find a related test for that project file. "--passWithNoTests", which means if there are no tests matching the changed files in a given commit, it's okay for just to pass with success, and finally, we pass the "{staged_files}".
[08:17 - 08:29] Vitest is going to be very similar. We're going to run "pnpm vitest --related -run -no-file-parallelism {staged_files}".
[08:30 - 08:53] I'm going to create the related commands where frontend-nextjs is going to be using jest, while frontend-vite and server-express are going to be using Vitest. And I'm also going to add a "root:" prefix to my original three commands that run pretty-quick Knip and Sherif.
[08:54 - 09:08] Go back to the command line, run this again, and we can see that our additional test commands are now executed. Finally, we're going to perform the optimization where we replace pnpm with directly running the bin stubs.
[09:09 - 09:25] And if we run the commands again we're going to see that they execute slightly faster, about 100 to 200 milliseconds. Depending on your team's preferences, two seconds for correctness checks before each commit might be too much.
[09:26 - 09:40] Some people might prefer to just entirely rely on CI, so we're going to create the option to skip these checks. Lefthook supports local overrides using the file lefthook-local.yml.
[09:41 - 09:58] So I'm going to add that file to my .gitignore, then I will open this local file side by side with my existing configuration. I'm going to copy over our configs and I'm going to set "skip: true" for all of them.
[09:59 - 10:30] Now that that's done I'm going to save the file, go back to the command line and try running pre-commit checks again, and we're going to see that all of our workspace checks are going to be skipped because of our overriding settings. So this way we give developers the option to skip some checks in their local commits if they don't want the extra security of these checks, and instead prefer to rely on CI.
[10:31 - 10:47] Finally, I'm going to reset all my git changes. I'm going to back out the changes that I made to frontend-nextjs and server-express that forced those workspaces to be added to our checks and I'm going to run "git status".
[10:48 - 10:57] For some reason we have the lefthook-local.yml config file as a new file to add. Let's check our .gitignore. And yes, I have a typo.
[10:58 - 11:07] It should be lefthook-local.yml. With that fix that go back to the command line run "git status" again.
[11:08 - 11:22] Now we see the expected list of files, so let's look at the changes one by one, by doing "git add -p" which means patch. We have added lefthook-local.yml to our git ignore.
[11:23 - 11:34] We have ignored lefthook.yml in our Knip configuration. And finally we have all the changes that we've made in our Lefthook config.
[11:35 - 11:41] I'm going to commit this as "Add per workspace pre-commit checks" and we're done.