From d64aefc10be3b27af362641312511042812565fe Mon Sep 17 00:00:00 2001 From: Linear Date: Fri, 14 Aug 2026 08:04:56 +0000 Subject: [PATCH] Support negated release path filters Co-authored-by: linear-code[bot] <222613912+linear-code[bot]@users.noreply.github.com> --- README.md | 5 ++++- src/git.test.ts | 36 ++++++++++++++++++++++++++++++++++++ src/git.ts | 20 ++++++++++++++------ 3 files changed, 54 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index f9eb69c..0a8140f 100644 --- a/README.md +++ b/README.md @@ -215,9 +215,12 @@ linear-release sync --include-paths="apps/mobile/**" # Multiple patterns linear-release sync --include-paths="apps/mobile/**,packages/shared/**" + +# Include everything except mobile and desktop apps +linear-release sync --include-paths='!apps/mobile/**,!apps/desktop/**' ``` -Patterns use [Git pathspec](https://git-scm.com/docs/gitglossary#Documentation/gitglossary.txt-pathspec) glob syntax. Paths are relative to the repository root. +Patterns use [Git pathspec](https://git-scm.com/docs/gitglossary#Documentation/gitglossary.txt-pathspec) glob syntax. Paths are relative to the repository root. Prefix a pattern with `!` to exclude matching paths. Negated patterns can be combined with positive patterns, or used on their own to include everything except the excluded paths. Path patterns can also be configured in your pipeline settings in Linear. If both are set, the CLI `--include-paths` option takes precedence. diff --git a/src/git.test.ts b/src/git.test.ts index 73ea498..1237c4c 100644 --- a/src/git.test.ts +++ b/src/git.test.ts @@ -38,6 +38,11 @@ describe("normalizePathspec", () => { expect(normalizePathspec(" android/** ")).toBe("android/**"); }); + it("should preserve negation while normalizing the path", () => { + expect(normalizePathspec(" !./mobile/** ")).toBe("!mobile/**"); + expect(normalizePathspec("!/desktop/**")).toBe("!desktop/**"); + }); + it("should handle empty strings", () => { expect(normalizePathspec("")).toBe(""); }); @@ -75,6 +80,19 @@ describe("buildPathspecArgs", () => { ":(top,glob)ios/**", ]); }); + + it("should build exclude pathspecs for negated patterns", () => { + expect(buildPathspecArgs(["**", "!./mobile/**", " !/desktop/** "])).toEqual([ + "--", + ":(top,glob)**", + ":(top,glob,exclude)mobile/**", + ":(top,glob,exclude)desktop/**", + ]); + }); + + it("should ignore an empty negated pattern", () => { + expect(buildPathspecArgs(["!"])).toEqual([]); + }); }); describe("extractBranchName", () => { @@ -778,6 +796,24 @@ describe("getCommitContextsBetweenShas", () => { expect(withGithubFilter[0]?.sha).toBe(repo.commits.second); }); + it("should exclude commits matching negated path patterns", async () => { + const result = await getCommitContextsBetweenShas(repo.commits.first, repo.commits.third, { + includePaths: ["**", "!.github/**"], + cwd: repo.cwd, + }); + + expect(result.map((commit) => commit.sha)).toEqual([repo.commits.third]); + }); + + it("should support exclusion-only path patterns", async () => { + const result = await getCommitContextsBetweenShas(repo.commits.first, repo.commits.third, { + includePaths: ["!.github/**"], + cwd: repo.cwd, + }); + + expect(result.map((commit) => commit.sha)).toEqual([repo.commits.third]); + }); + it("should resolve paths relative to repo root even when process.cwd() is a subdirectory", async () => { // Simulates running the CLI from a subdirectory (e.g., mobile-ios/ci_scripts) // while using paths relative to the repo root (e.g., src/**) diff --git a/src/git.ts b/src/git.ts index 72512b5..db2e2d8 100644 --- a/src/git.ts +++ b/src/git.ts @@ -2,17 +2,25 @@ import { execFileSync, execSync, spawn } from "node:child_process"; import type { CommitContext, GitInfo } from "./types"; import { error as logError, verbose, warn } from "./log"; -/** Strips leading "./" or "/" so paths are clean for git pathspec. */ +/** Preserves a leading "!" while cleaning the path for use as a git pathspec. */ export function normalizePathspec(pattern: string): string { - return pattern.replace(/^(\.\/|\/)+/, "").trim(); + const trimmed = pattern.trim(); + const exclude = trimmed.startsWith("!"); + const path = (exclude ? trimmed.slice(1) : trimmed).replace(/^(\.\/|\/)+/, ""); + return exclude ? `!${path}` : path; } /** - * Builds git pathspec arguments from include patterns. + * Builds git pathspec arguments from include and exclude patterns. * - * Uses `:(top,glob)` pathspec prefix: + * Uses `:(top,glob)` pathspec prefix for includes and + * `:(top,glob,exclude)` for patterns prefixed with `!`: * - `top`: paths are relative to repo root, not the current working directory * - `glob`: enables `**` for recursive matching (e.g., "src/**") + * - `exclude`: removes matching paths after positive pathspecs are resolved + * + * Git treats an exclusion-only pathspec as matching everything first, which + * lets configurations use `!mobile/**` without also specifying `**`. * * @see https://git-scm.com/docs/gitglossary#Documentation/gitglossary.txt-aiddefpathspec */ @@ -22,8 +30,8 @@ export function buildPathspecArgs(includePaths: string[] | null): string[] { } const patterns = includePaths .map((p) => normalizePathspec(p)) - .filter((p) => p.length > 0) - .map((p) => `:(top,glob)${p}`); + .filter((p) => p.length > 0 && p !== "!") + .map((p) => (p.startsWith("!") ? `:(top,glob,exclude)${p.slice(1)}` : `:(top,glob)${p}`)); if (patterns.length === 0) { return []; }