aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/packages/turbo-ignore
diff options
context:
space:
mode:
author简律纯 <hsiangnianian@outlook.com>2023-04-28 01:36:44 +0800
committer简律纯 <hsiangnianian@outlook.com>2023-04-28 01:36:44 +0800
commitdd84b9d64fb98746a230cd24233ff50a562c39c9 (patch)
treeb583261ef00b3afe72ec4d6dacb31e57779a6faf /packages/turbo-ignore
parent0b46fcd72ac34382387b2bcf9095233efbcc52f4 (diff)
downloadHydroRoll-dd84b9d64fb98746a230cd24233ff50a562c39c9.tar.gz
HydroRoll-dd84b9d64fb98746a230cd24233ff50a562c39c9.zip
Diffstat (limited to 'packages/turbo-ignore')
-rw-r--r--packages/turbo-ignore/README.md99
-rw-r--r--packages/turbo-ignore/__fixtures__/app/package.json11
-rw-r--r--packages/turbo-ignore/__fixtures__/invalid-app/package.json10
-rw-r--r--packages/turbo-ignore/__fixtures__/no-app/index.js0
-rw-r--r--packages/turbo-ignore/__tests__/args.test.ts109
-rw-r--r--packages/turbo-ignore/__tests__/checkCommit.test.ts229
-rw-r--r--packages/turbo-ignore/__tests__/errors.test.ts46
-rw-r--r--packages/turbo-ignore/__tests__/getComparison.test.ts61
-rw-r--r--packages/turbo-ignore/__tests__/getTask.test.ts27
-rw-r--r--packages/turbo-ignore/__tests__/getWorkspace.test.ts62
-rw-r--r--packages/turbo-ignore/__tests__/ignore.test.ts578
-rw-r--r--packages/turbo-ignore/jest.config.js18
-rw-r--r--packages/turbo-ignore/package.json40
-rw-r--r--packages/turbo-ignore/src/args.ts89
-rw-r--r--packages/turbo-ignore/src/checkCommit.ts104
-rw-r--r--packages/turbo-ignore/src/errors.ts43
-rw-r--r--packages/turbo-ignore/src/getComparison.ts39
-rw-r--r--packages/turbo-ignore/src/getTask.ts13
-rw-r--r--packages/turbo-ignore/src/getWorkspace.ts37
-rw-r--r--packages/turbo-ignore/src/ignore.ts125
-rw-r--r--packages/turbo-ignore/src/index.ts6
-rw-r--r--packages/turbo-ignore/src/logger.ts16
-rw-r--r--packages/turbo-ignore/src/types.ts23
-rw-r--r--packages/turbo-ignore/tsconfig.json6
-rw-r--r--packages/turbo-ignore/tsup.config.ts9
25 files changed, 1800 insertions, 0 deletions
diff --git a/packages/turbo-ignore/README.md b/packages/turbo-ignore/README.md
new file mode 100644
index 0000000..866c0e7
--- /dev/null
+++ b/packages/turbo-ignore/README.md
@@ -0,0 +1,99 @@
+# `turbo-ignore`
+
+To get started, use the following command as your [Ignored Build Step](https://vercel.com/docs/concepts/projects/overview#ignored-build-step):
+
+```sh
+$ npx turbo-ignore
+```
+
+This uses `turbo` to automatically determine if the current app has new changes that need to be deployed.
+
+## Usage
+
+Use `npx turbo-ignore --help` to see list of options:
+
+```sh
+turbo-ignore
+
+Automatically ignore builds that have no changes
+
+Usage:
+ $ npx turbo-ignore [<workspace>] [flags...]
+
+If <workspace> is not provided, it will be inferred from the "name"
+field of the "package.json" located at the current working directory.
+
+Flags:
+ --fallback=<ref> On Vercel, if no previously deployed SHA is available to compare against,
+ fallback to comparing against the provided ref [default: None]
+ --help, -h Show this help message
+ --version, -v Show the version of this script
+
+---
+
+turbo-ignore will also check for special commit messages to indicate if a build should be skipped or not.
+
+Skip turbo-ignore check and automatically ignore:
+ - [skip ci]
+ - [ci skip]
+ - [no ci]
+ - [skip vercel]
+ - [vercel skip]
+ - [vercel skip <workspace>]
+
+Skip turbo-ignore check and automatically deploy:
+ - [vercel deploy]
+ - [vercel build]
+ - [vercel deploy <workspace>]
+ - [vercel build <workspace>]
+```
+
+### Examples
+
+```sh
+npx turbo-ignore
+```
+
+> Only build if there are changes to the workspace in the current working directory, or any of it's dependencies. On Vercel, compare against the last successful deployment for the current branch. When not on Vercel, compare against the parent commit (`HEAD^`).
+
+---
+
+```sh
+npx turbo-ignore docs
+```
+
+> Only build if there are changes to the `docs` workspace, or any of its dependencies. On Vercel, compare against the last successful deployment for the current branch. When not on Vercel compare against the parent commit (`HEAD^`).
+
+---
+
+```sh
+npx turbo-ignore --fallback=HEAD~10
+```
+
+> Only build if there are changes to the workspace in the current working directory, or any of it's dependencies. On Vercel, compare against the last successful deployment for the current branch. If this does not exist (first deploy of the branch), compare against the previous 10 commits. When not on Vercel, always compare against the parent commit (`HEAD^`).
+
+---
+
+```sh
+npx turbo-ignore --fallback=HEAD^
+```
+
+> Only build if there are changes to the workspace in the current working directory, or any of it's dependencies. On Vercel, compare against the last successful deployment for the current branch. If this does not exist (first deploy of the branch), compare against the parent commit (`HEAD^`). When not on Vercel, always compare against the parent commit (`HEAD^`).
+
+## How it Works
+
+`turbo-ignore` determines if a build should continue by analyzing the package dependency graph of the given workspace.
+
+The _given workspace_ is determined by reading the "name" field in the "package.json" file located at the current working directory, or by passing in a workspace name as the first argument to `turbo-ignore`.
+
+Next, it uses `turbo run build --dry` to determine if the given workspace, _or any dependencies of the workspace_, have changed since the previous commit.
+
+**NOTE:** `turbo` determines dependencies from reading the dependency graph of the given workspace. This means a workspace **must** be listed as a `dependency` (or `devDependency`) in the given workspaces `package.json` for `turbo` to recognize it.
+
+When deploying on [Vercel](https://vercel.com), `turbo-ignore` can make a more accurate decision by comparing between the current commit, and the last successfully deployed commit for the current branch.
+
+**NOTE:** By default on Vercel, `turbo-ignore` will always deploy the first commit of a new branch. This behavior can be changed by providing the `ref` to compare against to the `--fallback` flag. See the [Examples](#Examples) section for more details.
+
+---
+
+For more information about Turborepo, visit [turbo.build](https://turbo.build) and follow us on Twitter ([@turborepo](https://twitter.com/turborepo))!
diff --git a/packages/turbo-ignore/__fixtures__/app/package.json b/packages/turbo-ignore/__fixtures__/app/package.json
new file mode 100644
index 0000000..17d7c56
--- /dev/null
+++ b/packages/turbo-ignore/__fixtures__/app/package.json
@@ -0,0 +1,11 @@
+{
+ "name": "test-app",
+ "private": true,
+ "version": "1.0.0",
+ "description": "",
+ "main": "index.js",
+ "scripts": {
+ "test": "echo \"Error: no test specified\" && exit 1"
+ },
+ "author": "vercel"
+}
diff --git a/packages/turbo-ignore/__fixtures__/invalid-app/package.json b/packages/turbo-ignore/__fixtures__/invalid-app/package.json
new file mode 100644
index 0000000..ee2f59b
--- /dev/null
+++ b/packages/turbo-ignore/__fixtures__/invalid-app/package.json
@@ -0,0 +1,10 @@
+{
+ "private": true,
+ "version": "1.0.0",
+ "description": "",
+ "main": "index.js",
+ "scripts": {
+ "test": "echo \"Error: no test specified\" && exit 1"
+ },
+ "author": "vercel"
+}
diff --git a/packages/turbo-ignore/__fixtures__/no-app/index.js b/packages/turbo-ignore/__fixtures__/no-app/index.js
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/packages/turbo-ignore/__fixtures__/no-app/index.js
diff --git a/packages/turbo-ignore/__tests__/args.test.ts b/packages/turbo-ignore/__tests__/args.test.ts
new file mode 100644
index 0000000..f546247
--- /dev/null
+++ b/packages/turbo-ignore/__tests__/args.test.ts
@@ -0,0 +1,109 @@
+import parseArgs, { help } from "../src/args";
+import pkg from "../package.json";
+import { spyConsole, spyExit } from "@turbo/test-utils";
+
+describe("parseArgs()", () => {
+ const mockConsole = spyConsole();
+ const mockExit = spyExit();
+
+ it("does not throw with no args", async () => {
+ const result = parseArgs({ argv: [] });
+ expect(result.workspace).toBe(undefined);
+ expect(result.fallback).toBe(undefined);
+ expect(result.task).toBe(undefined);
+ });
+
+ it("outputs help text (--help)", async () => {
+ parseArgs({ argv: ["--help"] });
+ expect(mockExit.exit).toHaveBeenCalledWith(0);
+ expect(mockConsole.log).toHaveBeenCalledWith(help);
+ });
+
+ it("outputs help text (-h)", async () => {
+ parseArgs({ argv: ["-h"] });
+ expect(mockExit.exit).toHaveBeenCalledWith(0);
+ expect(mockConsole.log).toHaveBeenCalledWith(help);
+ });
+
+ it("outputs version text (--version)", async () => {
+ parseArgs({ argv: ["--version"] });
+ expect(mockExit.exit).toHaveBeenCalledWith(0);
+ expect(mockConsole.log).toHaveBeenCalledWith(pkg.version);
+ });
+
+ it("outputs version text (-v)", async () => {
+ parseArgs({ argv: ["-v"] });
+ expect(mockExit.exit).toHaveBeenCalledWith(0);
+ expect(mockConsole.log).toHaveBeenCalledWith(pkg.version);
+ });
+
+ it("correctly finds workspace", async () => {
+ const result = parseArgs({ argv: ["this-workspace"] });
+ expect(result.workspace).toBe("this-workspace");
+ expect(result.fallback).toBe(undefined);
+ expect(result.task).toBe(undefined);
+ expect(mockExit.exit).toHaveBeenCalledTimes(0);
+ });
+
+ it("correctly finds fallback", async () => {
+ const result = parseArgs({ argv: ["--fallback=HEAD^"] });
+ expect(result.workspace).toBe(undefined);
+ expect(result.fallback).toBe("HEAD^");
+ expect(result.task).toBe(undefined);
+ expect(mockExit.exit).toHaveBeenCalledTimes(0);
+ });
+
+ it("correctly finds task", async () => {
+ const result = parseArgs({ argv: ["--task=some-workspace#build"] });
+ expect(result.workspace).toBe(undefined);
+ expect(result.fallback).toBe(undefined);
+ expect(result.task).toBe("some-workspace#build");
+ expect(mockExit.exit).toHaveBeenCalledTimes(0);
+ });
+
+ it("uses default fallback if incorrectly specified", async () => {
+ const result = parseArgs({ argv: ["--fallback"] });
+ expect(result.workspace).toBe(undefined);
+ expect(result.fallback).toBe(undefined);
+ expect(result.task).toBe(undefined);
+ expect(mockExit.exit).toHaveBeenCalledTimes(0);
+ });
+
+ it("uses default fallback if empty string", async () => {
+ const result = parseArgs({ argv: ["--fallback="] });
+ expect(result.workspace).toBe(undefined);
+ expect(result.fallback).toBe(undefined);
+ expect(result.task).toBe(undefined);
+ expect(mockExit.exit).toHaveBeenCalledTimes(0);
+ });
+
+ it("uses default task if incorrectly specified", async () => {
+ const result = parseArgs({ argv: ["--task"] });
+ expect(result.workspace).toBe(undefined);
+ expect(result.fallback).toBe(undefined);
+ expect(result.task).toBe(undefined);
+ expect(mockExit.exit).toHaveBeenCalledTimes(0);
+ });
+
+ it("uses default task if empty string", async () => {
+ const result = parseArgs({ argv: ["--task="] });
+ expect(result.workspace).toBe(undefined);
+ expect(result.fallback).toBe(undefined);
+ expect(result.task).toBe(undefined);
+ expect(mockExit.exit).toHaveBeenCalledTimes(0);
+ });
+
+ it("correctly finds fallback and workspace", async () => {
+ const result = parseArgs({
+ argv: [
+ "this-workspace",
+ "--fallback=HEAD~10",
+ "--task=some-workspace#build",
+ ],
+ });
+ expect(result.workspace).toBe("this-workspace");
+ expect(result.fallback).toBe("HEAD~10");
+ expect(result.task).toBe("some-workspace#build");
+ expect(mockExit.exit).toHaveBeenCalledTimes(0);
+ });
+});
diff --git a/packages/turbo-ignore/__tests__/checkCommit.test.ts b/packages/turbo-ignore/__tests__/checkCommit.test.ts
new file mode 100644
index 0000000..e7e4a5f
--- /dev/null
+++ b/packages/turbo-ignore/__tests__/checkCommit.test.ts
@@ -0,0 +1,229 @@
+import child_process from "child_process";
+import { checkCommit } from "../src/checkCommit";
+import { mockEnv } from "@turbo/test-utils";
+
+describe("checkCommit()", () => {
+ describe("on Vercel", () => {
+ mockEnv();
+
+ describe("for all workspaces", () => {
+ it("results in continue when no special commit messages are found", async () => {
+ process.env.VERCEL = "1";
+ process.env.VERCEL_GIT_COMMIT_MESSAGE = "fixing a test";
+ expect(checkCommit({ workspace: "test-workspace" })).toEqual({
+ result: "continue",
+ scope: "global",
+ reason: "No deploy or skip string found in commit message.",
+ });
+ });
+
+ it("results in conflict when deploy and skip commit messages are found", async () => {
+ process.env.VERCEL = "1";
+ process.env.VERCEL_GIT_COMMIT_MESSAGE =
+ "deploying [vercel deploy] and skipping [vercel skip]";
+ expect(checkCommit({ workspace: "test-workspace" })).toEqual({
+ result: "conflict",
+ scope: "global",
+ reason:
+ "Conflicting commit messages found: [vercel deploy] and [vercel skip]",
+ });
+ });
+
+ it("results in deploy when deploy commit message is found", async () => {
+ process.env.VERCEL = "1";
+ process.env.VERCEL_GIT_COMMIT_MESSAGE = "deploying [vercel deploy]";
+ expect(checkCommit({ workspace: "test-workspace" })).toEqual({
+ result: "deploy",
+ scope: "global",
+ reason: "Found commit message: [vercel deploy]",
+ });
+ });
+
+ it("results in skip when skip commit message is found", async () => {
+ process.env.VERCEL = "1";
+ process.env.VERCEL_GIT_COMMIT_MESSAGE = "skip deployment [vercel skip]";
+ expect(checkCommit({ workspace: "test-workspace" })).toEqual({
+ result: "skip",
+ scope: "global",
+ reason: "Found commit message: [vercel skip]",
+ });
+ });
+ });
+
+ describe("for specific workspaces", () => {
+ it("results in continue when no special commit messages are found", async () => {
+ process.env.VERCEL = "1";
+ process.env.VERCEL_GIT_COMMIT_MESSAGE =
+ "fixing a test in test-workspace";
+ expect(checkCommit({ workspace: "test-workspace" })).toEqual({
+ result: "continue",
+ scope: "global",
+ reason: "No deploy or skip string found in commit message.",
+ });
+ });
+
+ it("results in conflict when deploy and skip commit messages are found", async () => {
+ process.env.VERCEL = "1";
+ process.env.VERCEL_GIT_COMMIT_MESSAGE =
+ "deploying [vercel deploy test-workspace] and skipping [vercel skip test-workspace]";
+ expect(checkCommit({ workspace: "test-workspace" })).toEqual({
+ result: "conflict",
+ scope: "workspace",
+ reason:
+ "Conflicting commit messages found: [vercel deploy test-workspace] and [vercel skip test-workspace]",
+ });
+ });
+
+ it("results in deploy when deploy commit message is found", async () => {
+ process.env.VERCEL = "1";
+ process.env.VERCEL_GIT_COMMIT_MESSAGE =
+ "deploying [vercel deploy test-workspace]";
+ expect(checkCommit({ workspace: "test-workspace" })).toEqual({
+ result: "deploy",
+ scope: "workspace",
+ reason: "Found commit message: [vercel deploy test-workspace]",
+ });
+ });
+
+ it("results in skip when skip commit message is found", async () => {
+ process.env.VERCEL = "1";
+ process.env.VERCEL_GIT_COMMIT_MESSAGE =
+ "skip deployment [vercel skip test-workspace]";
+ expect(checkCommit({ workspace: "test-workspace" })).toEqual({
+ result: "skip",
+ scope: "workspace",
+ reason: "Found commit message: [vercel skip test-workspace]",
+ });
+ });
+ });
+ });
+ describe("Not on Vercel", () => {
+ describe("for all workspaces", () => {
+ it("results in continue when no special commit messages are found", async () => {
+ const commitBody = "fixing a test";
+ const mockExecSync = jest
+ .spyOn(child_process, "execSync")
+ .mockImplementation((_) => commitBody);
+
+ expect(checkCommit({ workspace: "test-workspace" })).toEqual({
+ result: "continue",
+ scope: "global",
+ reason: "No deploy or skip string found in commit message.",
+ });
+ expect(mockExecSync).toHaveBeenCalledWith("git show -s --format=%B");
+ mockExecSync.mockRestore();
+ });
+
+ it("results in conflict when deploy and skip commit messages are found", async () => {
+ const commitBody =
+ "deploying [vercel deploy] and skipping [vercel skip]";
+ const mockExecSync = jest
+ .spyOn(child_process, "execSync")
+ .mockImplementation((_) => commitBody);
+
+ expect(checkCommit({ workspace: "test-workspace" })).toEqual({
+ result: "conflict",
+ scope: "global",
+ reason:
+ "Conflicting commit messages found: [vercel deploy] and [vercel skip]",
+ });
+ expect(mockExecSync).toHaveBeenCalledWith("git show -s --format=%B");
+ mockExecSync.mockRestore();
+ });
+
+ it("results in deploy when deploy commit message is found", async () => {
+ const commitBody = "deploying [vercel deploy]";
+ const mockExecSync = jest
+ .spyOn(child_process, "execSync")
+ .mockImplementation((_) => commitBody);
+
+ expect(checkCommit({ workspace: "test-workspace" })).toEqual({
+ result: "deploy",
+ scope: "global",
+ reason: "Found commit message: [vercel deploy]",
+ });
+ expect(mockExecSync).toHaveBeenCalledWith("git show -s --format=%B");
+ mockExecSync.mockRestore();
+ });
+
+ it("results in skip when skip commit message is found", async () => {
+ const commitBody = "skip deployment [vercel skip]";
+ const mockExecSync = jest
+ .spyOn(child_process, "execSync")
+ .mockImplementation((_) => commitBody);
+
+ expect(checkCommit({ workspace: "test-workspace" })).toEqual({
+ result: "skip",
+ scope: "global",
+ reason: "Found commit message: [vercel skip]",
+ });
+ expect(mockExecSync).toHaveBeenCalledWith("git show -s --format=%B");
+ mockExecSync.mockRestore();
+ });
+ });
+
+ describe("for specific workspaces", () => {
+ it("results in continue when no special commit messages are found", async () => {
+ const commitBody = "fixing a test in test-workspace";
+ const mockExecSync = jest
+ .spyOn(child_process, "execSync")
+ .mockImplementation((_) => commitBody);
+
+ expect(checkCommit({ workspace: "test-workspace" })).toEqual({
+ result: "continue",
+ scope: "global",
+ reason: "No deploy or skip string found in commit message.",
+ });
+ expect(mockExecSync).toHaveBeenCalledWith("git show -s --format=%B");
+ mockExecSync.mockRestore();
+ });
+
+ it("results in conflict when deploy and skip commit messages are found", async () => {
+ const commitBody =
+ "deploying [vercel deploy test-workspace] and skipping [vercel skip test-workspace]";
+ const mockExecSync = jest
+ .spyOn(child_process, "execSync")
+ .mockImplementation((_) => commitBody);
+
+ expect(checkCommit({ workspace: "test-workspace" })).toEqual({
+ result: "conflict",
+ scope: "workspace",
+ reason:
+ "Conflicting commit messages found: [vercel deploy test-workspace] and [vercel skip test-workspace]",
+ });
+ expect(mockExecSync).toHaveBeenCalledWith("git show -s --format=%B");
+ mockExecSync.mockRestore();
+ });
+
+ it("results in deploy when deploy commit message is found", async () => {
+ const commitBody = "deploying [vercel deploy test-workspace]";
+ const mockExecSync = jest
+ .spyOn(child_process, "execSync")
+ .mockImplementation((_) => commitBody);
+
+ expect(checkCommit({ workspace: "test-workspace" })).toEqual({
+ result: "deploy",
+ scope: "workspace",
+ reason: "Found commit message: [vercel deploy test-workspace]",
+ });
+ expect(mockExecSync).toHaveBeenCalledWith("git show -s --format=%B");
+ mockExecSync.mockRestore();
+ });
+
+ it("results in skip when skip commit message is found", async () => {
+ const commitBody = "skip deployment [vercel skip test-workspace]";
+ const mockExecSync = jest
+ .spyOn(child_process, "execSync")
+ .mockImplementation((_) => commitBody);
+
+ expect(checkCommit({ workspace: "test-workspace" })).toEqual({
+ result: "skip",
+ scope: "workspace",
+ reason: "Found commit message: [vercel skip test-workspace]",
+ });
+ expect(mockExecSync).toHaveBeenCalledWith("git show -s --format=%B");
+ mockExecSync.mockRestore();
+ });
+ });
+ });
+});
diff --git a/packages/turbo-ignore/__tests__/errors.test.ts b/packages/turbo-ignore/__tests__/errors.test.ts
new file mode 100644
index 0000000..18f26bd
--- /dev/null
+++ b/packages/turbo-ignore/__tests__/errors.test.ts
@@ -0,0 +1,46 @@
+import { shouldWarn, NON_FATAL_ERRORS } from "../src/errors";
+
+describe("shouldWarn()", () => {
+ it("it detects errors when packageManager is missing", async () => {
+ const result = shouldWarn({
+ err: `run failed: We did not detect an in-use package manager for your project. Please set the "packageManager" property in your root package.json (https://nodejs.org/api/packages.html#packagemanager) or run \`npx @turbo/codemod add-package-manager\` in the root of your monorepo.`,
+ });
+ expect(result.code).toBe("NO_PACKAGE_MANAGER");
+ expect(result.level).toBe("warn");
+ expect(result.message).toBe(NON_FATAL_ERRORS.NO_PACKAGE_MANAGER.message);
+ });
+
+ it("it detects errors when yarn lockfile is missing", async () => {
+ const result = shouldWarn({
+ err: `* reading yarn.lock: open /test/../yarn.lock: no such file or directory`,
+ });
+ expect(result.code).toBe("MISSING_LOCKFILE");
+ expect(result.level).toBe("warn");
+ expect(result.message).toBe(NON_FATAL_ERRORS.MISSING_LOCKFILE.message);
+ });
+
+ it("it detects errors when pnpm lockfile is missing", async () => {
+ const result = shouldWarn({
+ err: `* reading pnpm-lock.yaml: open /test/../pnpm-lock.yaml: no such file or directory`,
+ });
+ expect(result.code).toBe("MISSING_LOCKFILE");
+ expect(result.level).toBe("warn");
+ expect(result.message).toBe(NON_FATAL_ERRORS.MISSING_LOCKFILE.message);
+ });
+
+ it("it detects errors when npm lockfile is missing", async () => {
+ const result = shouldWarn({
+ err: `* reading package-lock.json: open /test/../package-lock.json: no such file or directory`,
+ });
+ expect(result.code).toBe("MISSING_LOCKFILE");
+ expect(result.level).toBe("warn");
+ expect(result.message).toBe(NON_FATAL_ERRORS.MISSING_LOCKFILE.message);
+ });
+
+ it("it returns unknown errors", async () => {
+ const result = shouldWarn({ err: `something bad happened` });
+ expect(result.code).toBe("UNKNOWN_ERROR");
+ expect(result.level).toBe("error");
+ expect(result.message).toBe(`something bad happened`);
+ });
+});
diff --git a/packages/turbo-ignore/__tests__/getComparison.test.ts b/packages/turbo-ignore/__tests__/getComparison.test.ts
new file mode 100644
index 0000000..b5c74c7
--- /dev/null
+++ b/packages/turbo-ignore/__tests__/getComparison.test.ts
@@ -0,0 +1,61 @@
+import { getComparison } from "../src/getComparison";
+import { spyConsole, mockEnv } from "@turbo/test-utils";
+
+describe("getComparison()", () => {
+ mockEnv();
+ const mockConsole = spyConsole();
+ it("uses headRelative comparison when not running Vercel CI", async () => {
+ expect(getComparison({ workspace: "test-workspace" }))
+ .toMatchInlineSnapshot(`
+ Object {
+ "ref": "HEAD^",
+ "type": "headRelative",
+ }
+ `);
+ });
+
+ it("returns null when running in Vercel CI with no VERCEL_GIT_PREVIOUS_SHA", async () => {
+ process.env.VERCEL = "1";
+ process.env.VERCEL_GIT_COMMIT_REF = "my-branch";
+ expect(getComparison({ workspace: "test-workspace" })).toBeNull();
+ expect(mockConsole.log).toHaveBeenCalledWith(
+ "≫ ",
+ 'No previous deployments found for "test-workspace" on branch "my-branch".'
+ );
+ });
+
+ it("uses custom fallback when running in Vercel CI with no VERCEL_GIT_PREVIOUS_SHA", async () => {
+ process.env.VERCEL = "1";
+ process.env.VERCEL_GIT_COMMIT_REF = "my-branch";
+ expect(getComparison({ workspace: "test-workspace", fallback: "HEAD^2" }))
+ .toMatchInlineSnapshot(`
+ Object {
+ "ref": "HEAD^2",
+ "type": "customFallback",
+ }
+ `);
+ expect(mockConsole.log).toHaveBeenNthCalledWith(
+ 1,
+ "≫ ",
+ 'No previous deployments found for "test-workspace" on branch "my-branch".'
+ );
+ expect(mockConsole.log).toHaveBeenNthCalledWith(
+ 2,
+ "≫ ",
+ "Falling back to ref HEAD^2"
+ );
+ });
+
+ it("uses previousDeploy when running in Vercel CI with VERCEL_GIT_PREVIOUS_SHA", async () => {
+ process.env.VERCEL = "1";
+ process.env.VERCEL_GIT_PREVIOUS_SHA = "mygitsha";
+ process.env.VERCEL_GIT_COMMIT_REF = "my-branch";
+ expect(getComparison({ workspace: "test-workspace" }))
+ .toMatchInlineSnapshot(`
+ Object {
+ "ref": "mygitsha",
+ "type": "previousDeploy",
+ }
+ `);
+ });
+});
diff --git a/packages/turbo-ignore/__tests__/getTask.test.ts b/packages/turbo-ignore/__tests__/getTask.test.ts
new file mode 100644
index 0000000..a184893
--- /dev/null
+++ b/packages/turbo-ignore/__tests__/getTask.test.ts
@@ -0,0 +1,27 @@
+import { getTask } from "../src/getTask";
+import { spyConsole, validateLogs } from "@turbo/test-utils";
+
+describe("getWorkspace()", () => {
+ const mockConsole = spyConsole();
+ it("getTask defaults to build", async () => {
+ expect(getTask({})).toEqual("build");
+ validateLogs(
+ ['Using "build" as the task as it was unspecified'],
+ mockConsole.log,
+ { prefix: "≫ " }
+ );
+ });
+
+ it("getTask returns a quoted task if user-supplied", async () => {
+ expect(
+ getTask({
+ task: "workspace#task",
+ })
+ ).toEqual(`"workspace#task"`);
+ validateLogs(
+ ['Using "workspace#task" as the task from the arguments'],
+ mockConsole.log,
+ { prefix: "≫ " }
+ );
+ });
+});
diff --git a/packages/turbo-ignore/__tests__/getWorkspace.test.ts b/packages/turbo-ignore/__tests__/getWorkspace.test.ts
new file mode 100644
index 0000000..6d97fe2
--- /dev/null
+++ b/packages/turbo-ignore/__tests__/getWorkspace.test.ts
@@ -0,0 +1,62 @@
+import { getWorkspace } from "../src/getWorkspace";
+import { spyConsole, validateLogs } from "@turbo/test-utils";
+
+describe("getWorkspace()", () => {
+ const mockConsole = spyConsole();
+ it("getWorkspace returns workspace from arg", async () => {
+ expect(
+ getWorkspace({
+ workspace: "test-workspace",
+ })
+ ).toEqual("test-workspace");
+ validateLogs(
+ ['Using "test-workspace" as workspace from arguments'],
+ mockConsole.log,
+ { prefix: "≫ " }
+ );
+ });
+
+ it("getWorkspace returns workspace from package.json", async () => {
+ expect(
+ getWorkspace({
+ directory: "./__fixtures__/app",
+ })
+ ).toEqual("test-app");
+ expect(mockConsole.log).toHaveBeenCalledWith(
+ "≫ ",
+ 'Inferred "test-app" as workspace from "package.json"'
+ );
+ });
+
+ it("getWorkspace used current directory if not specified", async () => {
+ expect(getWorkspace({})).toEqual("turbo-ignore");
+ expect(mockConsole.log).toHaveBeenCalledWith(
+ "≫ ",
+ 'Inferred "turbo-ignore" as workspace from "package.json"'
+ );
+ });
+
+ it("getWorkspace returns null when no arg is provided and package.json is missing name field", async () => {
+ expect(
+ getWorkspace({
+ directory: "./__fixtures__/invalid-app",
+ })
+ ).toEqual(null);
+ expect(mockConsole.error).toHaveBeenCalledWith(
+ "≫ ",
+ '"__fixtures__/invalid-app/package.json" is missing the "name" field (required).'
+ );
+ });
+
+ it("getWorkspace returns null when no arg is provided and package.json can be found", async () => {
+ expect(
+ getWorkspace({
+ directory: "./__fixtures__/no-app",
+ })
+ ).toEqual(null);
+ expect(mockConsole.error).toHaveBeenCalledWith(
+ "≫ ",
+ '"__fixtures__/no-app/package.json" could not be found. turbo-ignore inferencing failed'
+ );
+ });
+});
diff --git a/packages/turbo-ignore/__tests__/ignore.test.ts b/packages/turbo-ignore/__tests__/ignore.test.ts
new file mode 100644
index 0000000..37908c5
--- /dev/null
+++ b/packages/turbo-ignore/__tests__/ignore.test.ts
@@ -0,0 +1,578 @@
+import child_process, { ChildProcess, ExecException } from "child_process";
+import turboIgnore from "../src/ignore";
+import {
+ spyConsole,
+ spyExit,
+ SpyExit,
+ mockEnv,
+ validateLogs,
+} from "@turbo/test-utils";
+
+function expectBuild(mockExit: SpyExit) {
+ expect(mockExit.exit).toHaveBeenCalledWith(1);
+}
+
+function expectIgnore(mockExit: SpyExit) {
+ expect(mockExit.exit).toHaveBeenCalledWith(0);
+}
+
+describe("turboIgnore()", () => {
+ mockEnv();
+ const mockExit = spyExit();
+ const mockConsole = spyConsole();
+
+ it("throws error and allows build when exec fails", async () => {
+ const mockExec = jest
+ .spyOn(child_process, "exec")
+ .mockImplementation((command, options, callback) => {
+ if (callback) {
+ return callback(
+ "error" as unknown as ExecException,
+ "stdout",
+ "stderr"
+ ) as unknown as ChildProcess;
+ }
+ return {} as unknown as ChildProcess;
+ });
+
+ turboIgnore({
+ args: { workspace: "test-workspace" },
+ });
+
+ expect(mockExec).toHaveBeenCalledWith(
+ "npx turbo run build --filter=test-workspace...[HEAD^] --dry=json",
+ expect.anything(),
+ expect.anything()
+ );
+
+ validateLogs(["UNKNOWN_ERROR: error"], mockConsole.error, {
+ prefix: "≫ ",
+ });
+
+ expectBuild(mockExit);
+ mockExec.mockRestore();
+ });
+
+ it("throws pretty error and allows build when exec fails", async () => {
+ const mockExec = jest
+ .spyOn(child_process, "exec")
+ .mockImplementation((command, options, callback) => {
+ if (callback) {
+ return callback(
+ {
+ message:
+ "run failed: We did not detect an in-use package manager for your project",
+ } as unknown as ExecException,
+ "stdout",
+ "stderr"
+ ) as unknown as ChildProcess;
+ }
+ return {} as unknown as ChildProcess;
+ });
+
+ turboIgnore({
+ args: { workspace: "test-workspace" },
+ });
+
+ expect(mockExec).toHaveBeenCalledWith(
+ "npx turbo run build --filter=test-workspace...[HEAD^] --dry=json",
+ expect.anything(),
+ expect.anything()
+ );
+
+ validateLogs(
+ [
+ `turbo-ignore could not complete - no package manager detected, please commit a lockfile, or set "packageManager" in your root "package.json"`,
+ ],
+ mockConsole.warn,
+ { prefix: "≫ " }
+ );
+
+ expectBuild(mockExit);
+ mockExec.mockRestore();
+ });
+
+ it("throws pretty error and allows build when can't find previous sha", async () => {
+ process.env.VERCEL = "1";
+ process.env.VERCEL_GIT_PREVIOUS_SHA = "too-far-back";
+ process.env.VERCEL_GIT_COMMIT_REF = "my-branch";
+ const mockExec = jest
+ .spyOn(child_process, "exec")
+ .mockImplementation((command, options, callback) => {
+ if (callback) {
+ return callback(
+ {
+ message:
+ " ERROR run failed: failed to resolve packages to run: commit too-far-back does not exist",
+ } as unknown as ExecException,
+ "stdout",
+ "stderr"
+ ) as unknown as ChildProcess;
+ }
+ return {} as unknown as ChildProcess;
+ });
+
+ turboIgnore({
+ args: { workspace: "test-workspace" },
+ });
+
+ expect(mockExec).toHaveBeenCalledWith(
+ "npx turbo run build --filter=test-workspace...[too-far-back] --dry=json",
+ expect.anything(),
+ expect.anything()
+ );
+
+ validateLogs(
+ [
+ `turbo-ignore could not complete - commit does not exist or is unreachable`,
+ ],
+ mockConsole.warn,
+ { prefix: "≫ " }
+ );
+
+ expectBuild(mockExit);
+ mockExec.mockRestore();
+ });
+
+ it("throws pretty error and allows build when fallback fails", async () => {
+ const mockExec = jest
+ .spyOn(child_process, "exec")
+ .mockImplementation((command, options, callback) => {
+ if (callback) {
+ return callback(
+ {
+ message:
+ "ERROR run failed: failed to resolve packages to run: commit HEAD^ does not exist",
+ } as unknown as ExecException,
+ "stdout",
+ "stderr"
+ ) as unknown as ChildProcess;
+ }
+ return {} as unknown as ChildProcess;
+ });
+
+ turboIgnore({
+ args: { workspace: "test-workspace", fallback: "HEAD^" },
+ });
+
+ expect(mockExec).toHaveBeenCalledWith(
+ "npx turbo run build --filter=test-workspace...[HEAD^] --dry=json",
+ expect.anything(),
+ expect.anything()
+ );
+
+ validateLogs(
+ [
+ `turbo-ignore could not complete - parent commit does not exist or is unreachable`,
+ ],
+ mockConsole.warn,
+ { prefix: "≫ " }
+ );
+
+ expectBuild(mockExit);
+ mockExec.mockRestore();
+ });
+
+ it("skips checks and allows build when no workspace can be found", async () => {
+ turboIgnore({
+ args: {
+ directory: "__fixtures__/no-app",
+ },
+ });
+ validateLogs(
+ [
+ () => [
+ "≫ ",
+ expect.stringContaining(
+ " could not be found. turbo-ignore inferencing failed"
+ ),
+ ],
+ ],
+ mockConsole.error,
+ { prefix: "≫ " }
+ );
+ expectBuild(mockExit);
+ });
+
+ it("skips checks and allows build when a workspace with no name is found", async () => {
+ turboIgnore({
+ args: {
+ directory: "__fixtures__/invalid-app",
+ },
+ });
+ validateLogs(
+ [
+ () => [
+ "≫ ",
+ expect.stringContaining(' is missing the "name" field (required).'),
+ ],
+ ],
+ mockConsole.error,
+ { prefix: "≫ " }
+ );
+ expectBuild(mockExit);
+ });
+
+ it("skips checks and allows build when no monorepo root can be found", async () => {
+ turboIgnore({
+ args: { directory: "/" },
+ });
+ expectBuild(mockExit);
+ expect(mockConsole.error).toHaveBeenLastCalledWith(
+ "≫ ",
+ "Monorepo root not found. turbo-ignore inferencing failed"
+ );
+ });
+
+ it("skips checks and allows build when TURBO_FORCE is set", async () => {
+ process.env.TURBO_FORCE = "true";
+ turboIgnore({
+ args: { workspace: "test-workspace" },
+ });
+ expect(mockConsole.log).toHaveBeenNthCalledWith(
+ 2,
+ "≫ ",
+ "`TURBO_FORCE` detected"
+ );
+ expectBuild(mockExit);
+ });
+
+ it("allows build when no comparison is returned", async () => {
+ process.env.VERCEL = "1";
+ process.env.VERCEL_GIT_PREVIOUS_SHA = "";
+ process.env.VERCEL_GIT_COMMIT_REF = "my-branch";
+ turboIgnore({
+ args: {
+ workspace: "test-app",
+ directory: "__fixtures__/app",
+ },
+ });
+ expect(mockConsole.log).toHaveBeenNthCalledWith(
+ 4,
+ "≫ ",
+ 'No previous deployments found for "test-app" on branch "my-branch".'
+ );
+ expectBuild(mockExit);
+ });
+
+ it("skips build for `previousDeploy` comparison with no changes", async () => {
+ process.env.VERCEL = "1";
+ process.env.VERCEL_GIT_PREVIOUS_SHA = "last-deployed-sha";
+ process.env.VERCEL_GIT_COMMIT_REF = "my-branch";
+ const mockExec = jest
+ .spyOn(child_process, "exec")
+ .mockImplementation((command, options, callback) => {
+ if (callback) {
+ return callback(
+ null,
+ '{"packages":[],"tasks":[]}',
+ "stderr"
+ ) as unknown as ChildProcess;
+ }
+ return {} as unknown as ChildProcess;
+ });
+ turboIgnore({
+ args: {
+ directory: "__fixtures__/app",
+ },
+ });
+ validateLogs(
+ [
+ "Using Turborepo to determine if this project is affected by the commit...\n",
+ 'Inferred "test-app" as workspace from "package.json"',
+ 'Using "build" as the task as it was unspecified',
+ `Found previous deployment ("last-deployed-sha") for \"test-app\" on branch \"my-branch\"`,
+ "Analyzing results of `npx turbo run build --filter=test-app...[last-deployed-sha] --dry=json`",
+ "This project and its dependencies are not affected",
+ () => expect.stringContaining("⏭ Ignoring the change"),
+ ],
+ mockConsole.log,
+ { prefix: "≫ " }
+ );
+
+ expectIgnore(mockExit);
+ mockExec.mockRestore();
+ });
+
+ it("allows build for `previousDeploy` comparison with changes", async () => {
+ process.env.VERCEL = "1";
+ process.env.VERCEL_GIT_PREVIOUS_SHA = "last-deployed-sha";
+ process.env.VERCEL_GIT_COMMIT_REF = "my-branch";
+ const mockExec = jest
+ .spyOn(child_process, "exec")
+ .mockImplementation((command, options, callback) => {
+ if (callback) {
+ return callback(
+ null,
+ '{"packages":["test-app"],"tasks":[]}',
+ "stderr"
+ ) as unknown as ChildProcess;
+ }
+ return {} as unknown as ChildProcess;
+ });
+ turboIgnore({
+ args: {
+ task: "workspace#build",
+ directory: "__fixtures__/app",
+ },
+ });
+ validateLogs(
+ [
+ "Using Turborepo to determine if this project is affected by the commit...\n",
+ 'Inferred "test-app" as workspace from "package.json"',
+ 'Using "workspace#build" as the task from the arguments',
+ 'Found previous deployment ("last-deployed-sha") for "test-app" on branch "my-branch"',
+ 'Analyzing results of `npx turbo run "workspace#build" --filter=test-app...[last-deployed-sha] --dry=json`',
+ 'This commit affects "test-app"',
+ () => expect.stringContaining("✓ Proceeding with deployment"),
+ ],
+ mockConsole.log,
+ { prefix: "≫ " }
+ );
+
+ expectBuild(mockExit);
+ mockExec.mockRestore();
+ });
+
+ it("allows build for `previousDeploy` comparison with single dependency change", async () => {
+ process.env.VERCEL = "1";
+ process.env.VERCEL_GIT_PREVIOUS_SHA = "last-deployed-sha";
+ process.env.VERCEL_GIT_COMMIT_REF = "my-branch";
+ const mockExec = jest
+ .spyOn(child_process, "exec")
+ .mockImplementation((command, options, callback) => {
+ if (callback) {
+ return callback(
+ null,
+ '{"packages":["test-app", "ui"],"tasks":[]}',
+ "stderr"
+ ) as unknown as ChildProcess;
+ }
+ return {} as unknown as ChildProcess;
+ });
+ turboIgnore({
+ args: {
+ directory: "__fixtures__/app",
+ },
+ });
+ validateLogs(
+ [
+ "Using Turborepo to determine if this project is affected by the commit...\n",
+ 'Inferred "test-app" as workspace from "package.json"',
+ 'Using "build" as the task as it was unspecified',
+ 'Found previous deployment ("last-deployed-sha") for "test-app" on branch "my-branch"',
+ "Analyzing results of `npx turbo run build --filter=test-app...[last-deployed-sha] --dry=json`",
+ 'This commit affects "test-app" and 1 dependency (ui)',
+ () => expect.stringContaining("✓ Proceeding with deployment"),
+ ],
+ mockConsole.log,
+ { prefix: "≫ " }
+ );
+
+ expectBuild(mockExit);
+ mockExec.mockRestore();
+ });
+
+ it("allows build for `previousDeploy` comparison with multiple dependency changes", async () => {
+ process.env.VERCEL = "1";
+ process.env.VERCEL_GIT_PREVIOUS_SHA = "last-deployed-sha";
+ process.env.VERCEL_GIT_COMMIT_REF = "my-branch";
+ const mockExec = jest
+ .spyOn(child_process, "exec")
+ .mockImplementation((command, options, callback) => {
+ if (callback) {
+ return callback(
+ null,
+ '{"packages":["test-app", "ui", "tsconfig"],"tasks":[]}',
+ "stderr"
+ ) as unknown as ChildProcess;
+ }
+ return {} as unknown as ChildProcess;
+ });
+ turboIgnore({
+ args: {
+ directory: "__fixtures__/app",
+ },
+ });
+ validateLogs(
+ [
+ "Using Turborepo to determine if this project is affected by the commit...\n",
+ 'Inferred "test-app" as workspace from "package.json"',
+ 'Using "build" as the task as it was unspecified',
+ 'Found previous deployment ("last-deployed-sha") for "test-app" on branch "my-branch"',
+ "Analyzing results of `npx turbo run build --filter=test-app...[last-deployed-sha] --dry=json`",
+ 'This commit affects "test-app" and 2 dependencies (ui, tsconfig)',
+ () => expect.stringContaining("✓ Proceeding with deployment"),
+ ],
+ mockConsole.log,
+ { prefix: "≫ " }
+ );
+
+ expectBuild(mockExit);
+ mockExec.mockRestore();
+ });
+
+ it("throws error and allows build when json cannot be parsed", async () => {
+ const mockExec = jest
+ .spyOn(child_process, "exec")
+ .mockImplementation((command, options, callback) => {
+ if (callback) {
+ return callback(null, "stdout", "stderr") as unknown as ChildProcess;
+ }
+ return {} as unknown as ChildProcess;
+ });
+
+ turboIgnore({
+ args: {
+ directory: "__fixtures__/app",
+ },
+ });
+
+ expect(mockExec).toHaveBeenCalledWith(
+ "npx turbo run build --filter=test-app...[HEAD^] --dry=json",
+ expect.anything(),
+ expect.anything()
+ );
+ validateLogs(
+ [
+ "Failed to parse JSON output from `npx turbo run build --filter=test-app...[HEAD^] --dry=json`.",
+ ],
+ mockConsole.error,
+ { prefix: "≫ " }
+ );
+
+ expectBuild(mockExit);
+ mockExec.mockRestore();
+ });
+
+ it("throws error and allows build when stdout is null", async () => {
+ const mockExec = jest
+ .spyOn(child_process, "exec")
+ .mockImplementation((command, options, callback) => {
+ if (callback) {
+ return callback(
+ null,
+ null as unknown as string,
+ "stderr"
+ ) as unknown as ChildProcess;
+ }
+ return {} as unknown as ChildProcess;
+ });
+
+ turboIgnore({
+ args: {
+ directory: "__fixtures__/app",
+ },
+ });
+
+ expect(mockExec).toHaveBeenCalledWith(
+ "npx turbo run build --filter=test-app...[HEAD^] --dry=json",
+ expect.anything(),
+ expect.anything()
+ );
+ validateLogs(
+ [
+ "Failed to parse JSON output from `npx turbo run build --filter=test-app...[HEAD^] --dry=json`.",
+ ],
+ mockConsole.error,
+ { prefix: "≫ " }
+ );
+
+ expectBuild(mockExit);
+ mockExec.mockRestore();
+ });
+
+ it("skips when commit message contains a skip string", async () => {
+ process.env.VERCEL = "1";
+ process.env.VERCEL_GIT_COMMIT_MESSAGE = "[vercel skip]";
+
+ turboIgnore({
+ args: {
+ directory: "__fixtures__/app",
+ },
+ });
+
+ validateLogs(
+ [
+ "Using Turborepo to determine if this project is affected by the commit...\n",
+ 'Inferred "test-app" as workspace from "package.json"',
+ 'Using "build" as the task as it was unspecified',
+ "Found commit message: [vercel skip]",
+ () => expect.stringContaining("⏭ Ignoring the change"),
+ ],
+ mockConsole.log,
+ { prefix: "≫ " }
+ );
+
+ expectIgnore(mockExit);
+ });
+
+ it("deploys when commit message contains a deploy string", async () => {
+ process.env.VERCEL = "1";
+ process.env.VERCEL_GIT_COMMIT_MESSAGE = "[vercel deploy]";
+
+ turboIgnore({
+ args: {
+ directory: "__fixtures__/app",
+ },
+ });
+
+ validateLogs(
+ [
+ "Using Turborepo to determine if this project is affected by the commit...\n",
+ 'Inferred "test-app" as workspace from "package.json"',
+ 'Using "build" as the task as it was unspecified',
+ "Found commit message: [vercel deploy]",
+ () => expect.stringContaining("✓ Proceeding with deployment"),
+ ],
+ mockConsole.log,
+ { prefix: "≫ " }
+ );
+
+ expectBuild(mockExit);
+ });
+
+ it("runs full turbo-ignore check when commit message contains a conflicting string", async () => {
+ process.env.VERCEL = "1";
+ process.env.VERCEL_GIT_COMMIT_MESSAGE = "[vercel deploy] [vercel skip]";
+ process.env.VERCEL_GIT_PREVIOUS_SHA = "last-deployed-sha";
+ process.env.VERCEL_GIT_COMMIT_REF = "my-branch";
+
+ const mockExec = jest
+ .spyOn(child_process, "exec")
+ .mockImplementation((command, options, callback) => {
+ if (callback) {
+ return callback(
+ null,
+ '{"packages":[],"tasks":[]}',
+ "stderr"
+ ) as unknown as ChildProcess;
+ }
+ return {} as unknown as ChildProcess;
+ });
+
+ turboIgnore({
+ args: {
+ directory: "__fixtures__/app",
+ },
+ });
+
+ validateLogs(
+ [
+ "Using Turborepo to determine if this project is affected by the commit...\n",
+ 'Inferred "test-app" as workspace from "package.json"',
+ 'Using "build" as the task as it was unspecified',
+ "Conflicting commit messages found: [vercel deploy] and [vercel skip]",
+ `Found previous deployment ("last-deployed-sha") for \"test-app\" on branch \"my-branch\"`,
+ "Analyzing results of `npx turbo run build --filter=test-app...[last-deployed-sha] --dry=json`",
+ "This project and its dependencies are not affected",
+ () => expect.stringContaining("⏭ Ignoring the change"),
+ ],
+ mockConsole.log,
+ { prefix: "≫ " }
+ );
+
+ expectIgnore(mockExit);
+ mockExec.mockRestore();
+ });
+});
diff --git a/packages/turbo-ignore/jest.config.js b/packages/turbo-ignore/jest.config.js
new file mode 100644
index 0000000..52ddbbc
--- /dev/null
+++ b/packages/turbo-ignore/jest.config.js
@@ -0,0 +1,18 @@
+/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
+module.exports = {
+ preset: "ts-jest/presets/js-with-ts",
+ testEnvironment: "node",
+ testPathIgnorePatterns: ["/__fixtures__/"],
+ coveragePathIgnorePatterns: ["/__fixtures__/"],
+ collectCoverage: true,
+ coverageThreshold: {
+ global: {
+ branches: 100,
+ functions: 100,
+ lines: 100,
+ statements: 100,
+ },
+ },
+ modulePathIgnorePatterns: ["<rootDir>/node_modules", "<rootDir>/dist"],
+ transformIgnorePatterns: ["/node_modules/(?!(ansi-regex)/)"],
+};
diff --git a/packages/turbo-ignore/package.json b/packages/turbo-ignore/package.json
new file mode 100644
index 0000000..0fae072
--- /dev/null
+++ b/packages/turbo-ignore/package.json
@@ -0,0 +1,40 @@
+{
+ "name": "turbo-ignore",
+ "version": "1.9.4-canary.2",
+ "description": "",
+ "homepage": "https://turbo.build/repo",
+ "keywords": [],
+ "author": "Jared Palmer",
+ "license": "MPL-2.0",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/vercel/turbo",
+ "directory": "packages/turbo-ignore"
+ },
+ "bugs": {
+ "url": "https://github.com/vercel/turbo/issues"
+ },
+ "files": [
+ "dist"
+ ],
+ "main": "dist/index.js",
+ "bin": "dist/index.js",
+ "scripts": {
+ "build": "tsup",
+ "test": "jest",
+ "lint": "eslint src/**/*.ts",
+ "check-types": "tsc --noEmit"
+ },
+ "devDependencies": {
+ "@types/jest": "^27.4.0",
+ "@types/node": "^16.11.12",
+ "eslint": "^8.20.0",
+ "jest": "^27.4.3",
+ "ts-jest": "^27.1.1",
+ "@turbo/tsconfig": "workspace:*",
+ "tsup": "^5.12.1",
+ "@turbo/test-utils": "workspace:^0.0.0",
+ "@turbo/utils": "workspace:*",
+ "typescript": "^4.7.4"
+ }
+}
diff --git a/packages/turbo-ignore/src/args.ts b/packages/turbo-ignore/src/args.ts
new file mode 100644
index 0000000..8d6015c
--- /dev/null
+++ b/packages/turbo-ignore/src/args.ts
@@ -0,0 +1,89 @@
+import pkg from "../package.json";
+import { TurboIgnoreArgs } from "./types";
+import {
+ skipAllCommits,
+ forceAllCommits,
+ skipWorkspaceCommits,
+ forceWorkspaceCommits,
+} from "./checkCommit";
+
+export const help = `
+turbo-ignore
+
+Automatically ignore builds that have no changes
+
+Usage:
+ $ npx turbo-ignore [<workspace>] [flags...]
+
+If <workspace> is not provided, it will be inferred from the "name"
+field of the "package.json" located at the current working directory.
+
+Flags:
+ --fallback=<ref> On Vercel, if no previously deployed SHA is available to compare against,
+ fallback to comparing against the provided ref
+ --help, -h Show this help message
+ --version, -v Show the version of this script
+
+---
+
+turbo-ignore will also check for special commit messages to indicate if a build should be skipped or not.
+
+Skip turbo-ignore check and automatically ignore:
+${[...skipAllCommits, ...skipWorkspaceCommits({ workspace: "<workspace>" })]
+ .map((msg) => ` - ${msg}`)
+ .join("\n")}
+
+Skip turbo-ignore check and automatically deploy:
+${[...forceAllCommits, ...forceWorkspaceCommits({ workspace: "<workspace>" })]
+ .map((msg) => ` - ${msg}`)
+ .join("\n")}
+`;
+
+// simple args parser because we don't want to pull in a dependency
+// and we don't need many features
+export default function parseArgs({
+ argv,
+}: {
+ argv: Array<string>;
+}): TurboIgnoreArgs {
+ const args: TurboIgnoreArgs = { directory: process.cwd() };
+
+ // find all flags
+ const flags = new Set(
+ argv
+ .filter((args) => args.startsWith("-"))
+ .map((flag) => flag.replace(/-/g, ""))
+ );
+
+ // handle help flag and exit
+ if (flags.has("help") || flags.has("h")) {
+ console.log(help);
+ process.exit(0);
+ }
+ // handle version flag and exit
+ if (flags.has("version") || flags.has("v")) {
+ console.log(pkg.version);
+ process.exit(0);
+ }
+
+ // set workspace (if provided)
+ if (argv.length && !argv[0].startsWith("-")) {
+ args.workspace = argv[0];
+ }
+
+ // set task (if provided)
+ const taskArgSentinel = "--task=";
+ const taskArg = argv.find((arg) => arg.startsWith(taskArgSentinel));
+ if (taskArg && taskArg.length > taskArgSentinel.length) {
+ args.task = taskArg.split("=")[1];
+ }
+
+ // set fallback (if provided)
+ const fallbackSentinel = "--fallback=";
+ const fallbackArg = argv.find((arg) => arg.startsWith(fallbackSentinel));
+ if (fallbackArg && fallbackArg.length > fallbackSentinel.length) {
+ args.fallback = fallbackArg.split("=")[1];
+ }
+
+ return args;
+}
diff --git a/packages/turbo-ignore/src/checkCommit.ts b/packages/turbo-ignore/src/checkCommit.ts
new file mode 100644
index 0000000..af6108e
--- /dev/null
+++ b/packages/turbo-ignore/src/checkCommit.ts
@@ -0,0 +1,104 @@
+import { execSync } from "child_process";
+
+export const skipAllCommits = [
+ `[skip ci]`,
+ `[ci skip]`,
+ `[no ci]`,
+ `[skip vercel]`,
+ `[vercel skip]`,
+];
+
+export const forceAllCommits = [`[vercel deploy]`, `[vercel build]`];
+
+export function skipWorkspaceCommits({ workspace }: { workspace: string }) {
+ return [`[vercel skip ${workspace}]`];
+}
+
+export function forceWorkspaceCommits({ workspace }: { workspace: string }) {
+ return [`[vercel deploy ${workspace}]`, `[vercel build ${workspace}]`];
+}
+
+export function getCommitDetails() {
+ // if we're on Vercel, use the provided commit message
+ if (process.env.VERCEL === "1") {
+ if (process.env.VERCEL_GIT_COMMIT_MESSAGE) {
+ return process.env.VERCEL_GIT_COMMIT_MESSAGE;
+ }
+ }
+ return execSync("git show -s --format=%B").toString();
+}
+
+export function checkCommit({ workspace }: { workspace: string }): {
+ result: "skip" | "deploy" | "continue" | "conflict";
+ scope: "global" | "workspace";
+ reason: string;
+} {
+ const commitMessage = getCommitDetails();
+ const findInCommit = (commit: string) => commitMessage.includes(commit);
+
+ // check workspace specific messages first
+ const forceWorkspaceDeploy = forceWorkspaceCommits({ workspace }).find(
+ findInCommit
+ );
+ const forceWorkspaceSkip = skipWorkspaceCommits({ workspace }).find(
+ findInCommit
+ );
+
+ if (forceWorkspaceDeploy && forceWorkspaceSkip) {
+ return {
+ result: "conflict",
+ scope: "workspace",
+ reason: `Conflicting commit messages found: ${forceWorkspaceDeploy} and ${forceWorkspaceSkip}`,
+ };
+ }
+
+ if (forceWorkspaceDeploy) {
+ return {
+ result: "deploy",
+ scope: "workspace",
+ reason: `Found commit message: ${forceWorkspaceDeploy}`,
+ };
+ }
+
+ if (forceWorkspaceSkip) {
+ return {
+ result: "skip",
+ scope: "workspace",
+ reason: `Found commit message: ${forceWorkspaceSkip}`,
+ };
+ }
+
+ // check global messages last
+ const forceDeploy = forceAllCommits.find(findInCommit);
+ const forceSkip = skipAllCommits.find(findInCommit);
+
+ if (forceDeploy && forceSkip) {
+ return {
+ result: "conflict",
+ scope: "global",
+ reason: `Conflicting commit messages found: ${forceDeploy} and ${forceSkip}`,
+ };
+ }
+
+ if (forceDeploy) {
+ return {
+ result: "deploy",
+ scope: "global",
+ reason: `Found commit message: ${forceDeploy}`,
+ };
+ }
+
+ if (forceSkip) {
+ return {
+ result: "skip",
+ scope: "global",
+ reason: `Found commit message: ${forceSkip}`,
+ };
+ }
+
+ return {
+ result: "continue",
+ scope: "global",
+ reason: `No deploy or skip string found in commit message.`,
+ };
+}
diff --git a/packages/turbo-ignore/src/errors.ts b/packages/turbo-ignore/src/errors.ts
new file mode 100644
index 0000000..f600dfb
--- /dev/null
+++ b/packages/turbo-ignore/src/errors.ts
@@ -0,0 +1,43 @@
+import { NonFatalErrorKey, NonFatalErrors } from "./types";
+
+export const NON_FATAL_ERRORS: NonFatalErrors = {
+ MISSING_LOCKFILE: {
+ regex:
+ /reading (yarn.lock|package-lock.json|pnpm-lock.yaml):.*?no such file or directory/,
+ message: `turbo-ignore could not complete - no lockfile found, please commit one to your repository`,
+ },
+ NO_PACKAGE_MANAGER: {
+ regex:
+ /run failed: We did not detect an in-use package manager for your project/,
+ message: `turbo-ignore could not complete - no package manager detected, please commit a lockfile, or set "packageManager" in your root "package.json"`,
+ },
+ UNREACHABLE_PARENT: {
+ regex: /failed to resolve packages to run: commit HEAD\^ does not exist/,
+ message: `turbo-ignore could not complete - parent commit does not exist or is unreachable`,
+ },
+ UNREACHABLE_COMMIT: {
+ regex: /commit \S+ does not exist/,
+ message: `turbo-ignore could not complete - commit does not exist or is unreachable`,
+ },
+};
+
+export function shouldWarn({ err }: { err: string }): {
+ level: "warn" | "error";
+ message: string;
+ code: NonFatalErrorKey | "UNKNOWN_ERROR";
+} {
+ const knownError = Object.keys(NON_FATAL_ERRORS).find((key) => {
+ const { regex } = NON_FATAL_ERRORS[key as NonFatalErrorKey];
+ return regex.test(err);
+ });
+
+ if (knownError) {
+ return {
+ level: "warn",
+ message: NON_FATAL_ERRORS[knownError as NonFatalErrorKey].message,
+ code: knownError as NonFatalErrorKey,
+ };
+ }
+
+ return { level: "error", message: err, code: "UNKNOWN_ERROR" };
+}
diff --git a/packages/turbo-ignore/src/getComparison.ts b/packages/turbo-ignore/src/getComparison.ts
new file mode 100644
index 0000000..a2ad61a
--- /dev/null
+++ b/packages/turbo-ignore/src/getComparison.ts
@@ -0,0 +1,39 @@
+import { info } from "./logger";
+import { TurboIgnoreArgs } from "./types";
+
+export interface GetComparisonArgs extends TurboIgnoreArgs {
+ // the workspace to check for changes
+ workspace: string;
+ // A ref/head to compare against if no previously deployed SHA is available
+ fallback?: string;
+}
+
+export function getComparison(args: GetComparisonArgs): {
+ ref: string;
+ type: "previousDeploy" | "headRelative" | "customFallback";
+} | null {
+ const { fallback, workspace } = args;
+ if (process.env.VERCEL === "1") {
+ if (process.env.VERCEL_GIT_PREVIOUS_SHA) {
+ // use the commit SHA of the last successful deployment for this project / branch
+ info(
+ `Found previous deployment ("${process.env.VERCEL_GIT_PREVIOUS_SHA}") for "${workspace}" on branch "${process.env.VERCEL_GIT_COMMIT_REF}"`
+ );
+ return {
+ ref: process.env.VERCEL_GIT_PREVIOUS_SHA,
+ type: "previousDeploy",
+ };
+ } else {
+ info(
+ `No previous deployments found for "${workspace}" on branch "${process.env.VERCEL_GIT_COMMIT_REF}".`
+ );
+ if (fallback) {
+ info(`Falling back to ref ${fallback}`);
+ return { ref: fallback, type: "customFallback" };
+ }
+
+ return null;
+ }
+ }
+ return { ref: "HEAD^", type: "headRelative" };
+}
diff --git a/packages/turbo-ignore/src/getTask.ts b/packages/turbo-ignore/src/getTask.ts
new file mode 100644
index 0000000..9e95e35
--- /dev/null
+++ b/packages/turbo-ignore/src/getTask.ts
@@ -0,0 +1,13 @@
+import { info } from "./logger";
+import { TurboIgnoreArgs } from "./types";
+
+export function getTask(args: TurboIgnoreArgs): string | null {
+ if (args.task) {
+ info(`Using "${args.task}" as the task from the arguments`);
+ return `"${args.task}"`;
+ }
+
+ info('Using "build" as the task as it was unspecified');
+
+ return "build";
+}
diff --git a/packages/turbo-ignore/src/getWorkspace.ts b/packages/turbo-ignore/src/getWorkspace.ts
new file mode 100644
index 0000000..e0b3167
--- /dev/null
+++ b/packages/turbo-ignore/src/getWorkspace.ts
@@ -0,0 +1,37 @@
+import fs from "fs";
+import path from "path";
+import { error, info } from "./logger";
+import { TurboIgnoreArgs } from "./types";
+
+export function getWorkspace(args: TurboIgnoreArgs): string | null {
+ const { directory = process.cwd(), workspace } = args;
+
+ // if the workspace is provided via args, use that
+ if (workspace) {
+ info(`Using "${workspace}" as workspace from arguments`);
+ return workspace;
+ }
+
+ // otherwise, try and infer it from a package.json in the current directory
+ const packageJsonPath = path.join(directory, "package.json");
+ try {
+ const raw = fs.readFileSync(packageJsonPath, "utf8");
+ const packageJsonContent: Record<string, string> & { name: string } =
+ JSON.parse(raw);
+
+ if (!packageJsonContent.name) {
+ error(`"${packageJsonPath}" is missing the "name" field (required).`);
+ return null;
+ }
+
+ info(
+ `Inferred "${packageJsonContent.name}" as workspace from "package.json"`
+ );
+ return packageJsonContent.name;
+ } catch (e) {
+ error(
+ `"${packageJsonPath}" could not be found. turbo-ignore inferencing failed`
+ );
+ return null;
+ }
+}
diff --git a/packages/turbo-ignore/src/ignore.ts b/packages/turbo-ignore/src/ignore.ts
new file mode 100644
index 0000000..a6f8f2e
--- /dev/null
+++ b/packages/turbo-ignore/src/ignore.ts
@@ -0,0 +1,125 @@
+import { exec } from "child_process";
+import path from "path";
+import { getTurboRoot } from "@turbo/utils";
+import { getComparison } from "./getComparison";
+import { getTask } from "./getTask";
+import { getWorkspace } from "./getWorkspace";
+import { info, warn, error } from "./logger";
+import { shouldWarn } from "./errors";
+import { TurboIgnoreArgs } from "./types";
+import { checkCommit } from "./checkCommit";
+
+function ignoreBuild() {
+ console.log("⏭ Ignoring the change");
+ return process.exit(0);
+}
+
+function continueBuild() {
+ console.log("✓ Proceeding with deployment");
+ return process.exit(1);
+}
+
+export default function turboIgnore({ args }: { args: TurboIgnoreArgs }) {
+ info(
+ `Using Turborepo to determine if this project is affected by the commit...\n`
+ );
+
+ // set default directory
+ args.directory = args.directory
+ ? path.resolve(args.directory)
+ : process.cwd();
+
+ // check for TURBO_FORCE and bail early if it's set
+ if (process.env.TURBO_FORCE === "true") {
+ info("`TURBO_FORCE` detected");
+ return continueBuild();
+ }
+
+ // find the monorepo root
+ const root = getTurboRoot(args.directory);
+ if (!root) {
+ error("Monorepo root not found. turbo-ignore inferencing failed");
+ return continueBuild();
+ }
+
+ // Find the workspace from the command-line args, or the package.json at the current directory
+ const workspace = getWorkspace(args);
+ if (!workspace) {
+ return continueBuild();
+ }
+
+ // Identify which task to execute from the command-line args
+ let task = getTask(args);
+
+ // check the commit message
+ const parsedCommit = checkCommit({ workspace });
+ if (parsedCommit.result === "skip") {
+ info(parsedCommit.reason);
+ return ignoreBuild();
+ }
+ if (parsedCommit.result === "deploy") {
+ info(parsedCommit.reason);
+ return continueBuild();
+ }
+ if (parsedCommit.result === "conflict") {
+ info(parsedCommit.reason);
+ }
+
+ // Get the start of the comparison (previous deployment when available, or previous commit by default)
+ const comparison = getComparison({ workspace, fallback: args.fallback });
+ if (!comparison) {
+ // This is either the first deploy of the project, or the first deploy for the branch, either way - build it.
+ return continueBuild();
+ }
+
+ // Build, and execute the command
+ const command = `npx turbo run ${task} --filter=${workspace}...[${comparison.ref}] --dry=json`;
+ info(`Analyzing results of \`${command}\``);
+ exec(
+ command,
+ {
+ cwd: root,
+ },
+ (err, stdout) => {
+ if (err) {
+ const { level, code, message } = shouldWarn({ err: err.message });
+ if (level === "warn") {
+ warn(message);
+ } else {
+ error(`${code}: ${err}`);
+ }
+ return continueBuild();
+ }
+
+ try {
+ const parsed = JSON.parse(stdout);
+ if (parsed == null) {
+ error(`Failed to parse JSON output from \`${command}\`.`);
+ return continueBuild();
+ }
+ const { packages } = parsed;
+ if (packages && packages.length > 0) {
+ if (packages.length === 1) {
+ info(`This commit affects "${workspace}"`);
+ } else {
+ // subtract 1 because the first package is the workspace itself
+ info(
+ `This commit affects "${workspace}" and ${packages.length - 1} ${
+ packages.length - 1 === 1 ? "dependency" : "dependencies"
+ } (${packages.slice(1).join(", ")})`
+ );
+ }
+
+ return continueBuild();
+ } else {
+ info(`This project and its dependencies are not affected`);
+ return ignoreBuild();
+ }
+ } catch (e) {
+ error(`Failed to parse JSON output from \`${command}\`.`);
+ error(e);
+ return continueBuild();
+ }
+ }
+ );
+}
diff --git a/packages/turbo-ignore/src/index.ts b/packages/turbo-ignore/src/index.ts
new file mode 100644
index 0000000..0c34d3a
--- /dev/null
+++ b/packages/turbo-ignore/src/index.ts
@@ -0,0 +1,6 @@
+#!/usr/bin/env node
+
+import turboIgnore from "./ignore";
+import parseArgs from "./args";
+
+turboIgnore({ args: parseArgs({ argv: process.argv.slice(2) }) });
diff --git a/packages/turbo-ignore/src/logger.ts b/packages/turbo-ignore/src/logger.ts
new file mode 100644
index 0000000..a7903ac
--- /dev/null
+++ b/packages/turbo-ignore/src/logger.ts
@@ -0,0 +1,16 @@
+// ≫
+const TURBO_IGNORE_PREFIX = "\u226B ";
+
+function info(...args: any[]) {
+ console.log(TURBO_IGNORE_PREFIX, ...args);
+}
+
+function error(...args: any[]) {
+ console.error(TURBO_IGNORE_PREFIX, ...args);
+}
+
+function warn(...args: any[]) {
+ console.warn(TURBO_IGNORE_PREFIX, ...args);
+}
+
+export { info, warn, error };
diff --git a/packages/turbo-ignore/src/types.ts b/packages/turbo-ignore/src/types.ts
new file mode 100644
index 0000000..07fac3f
--- /dev/null
+++ b/packages/turbo-ignore/src/types.ts
@@ -0,0 +1,23 @@
+export type NonFatalErrorKey =
+ | "MISSING_LOCKFILE"
+ | "NO_PACKAGE_MANAGER"
+ | "UNREACHABLE_PARENT"
+ | "UNREACHABLE_COMMIT";
+
+export interface NonFatalError {
+ regex: RegExp;
+ message: string;
+}
+
+export type NonFatalErrors = Record<NonFatalErrorKey, NonFatalError>;
+
+export interface TurboIgnoreArgs {
+ // the working directory to use when looking for a workspace
+ directory?: string;
+ // the workspace to check for changes
+ workspace?: string;
+ // the task to run, if not build
+ task?: string;
+ // A ref/head to compare against if no previously deployed SHA is available
+ fallback?: string;
+}
diff --git a/packages/turbo-ignore/tsconfig.json b/packages/turbo-ignore/tsconfig.json
new file mode 100644
index 0000000..0620a3c
--- /dev/null
+++ b/packages/turbo-ignore/tsconfig.json
@@ -0,0 +1,6 @@
+{
+ "extends": "@turbo/tsconfig/library.json",
+ "compilerOptions": {
+ "rootDir": "."
+ }
+}
diff --git a/packages/turbo-ignore/tsup.config.ts b/packages/turbo-ignore/tsup.config.ts
new file mode 100644
index 0000000..4d9d9bf
--- /dev/null
+++ b/packages/turbo-ignore/tsup.config.ts
@@ -0,0 +1,9 @@
+import { defineConfig, Options } from "tsup";
+
+export default defineConfig((options: Options) => ({
+ entry: ["src/index.ts"],
+ format: ["cjs"],
+ minify: true,
+ clean: true,
+ ...options,
+}));