From d64aefc10be3b27af362641312511042812565fe Mon Sep 17 00:00:00 2001 From: Linear Date: Fri, 14 Aug 2026 08:04:56 +0000 Subject: [PATCH 1/3] 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 []; } From eb5b7f2ddfa9b87f3c85e9c7468b5ad989df9875 Mon Sep 17 00:00:00 2001 From: Axel Niklasson Yun Date: Tue, 18 Aug 2026 11:52:14 +0200 Subject: [PATCH 2/3] Trim whitespace after negation so the exclusion is not silently dropped Co-Authored-By: Claude Fable 5 --- src/git.test.ts | 19 +++++++++++++++++++ src/git.ts | 2 +- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/git.test.ts b/src/git.test.ts index 1237c4c..3eb28d5 100644 --- a/src/git.test.ts +++ b/src/git.test.ts @@ -43,6 +43,11 @@ describe("normalizePathspec", () => { expect(normalizePathspec("!/desktop/**")).toBe("!desktop/**"); }); + it("should trim whitespace between the negation and the path", () => { + expect(normalizePathspec("! mobile/**")).toBe("!mobile/**"); + expect(normalizePathspec("! ./desktop/**")).toBe("!desktop/**"); + }); + it("should handle empty strings", () => { expect(normalizePathspec("")).toBe(""); }); @@ -92,6 +97,7 @@ describe("buildPathspecArgs", () => { it("should ignore an empty negated pattern", () => { expect(buildPathspecArgs(["!"])).toEqual([]); + expect(buildPathspecArgs(["! "])).toEqual([]); }); }); @@ -1182,6 +1188,19 @@ describe("merge commit handling", () => { expect(branchNames).toContain("feat/XYZ-2-impl"); }); + it("applies merge retention under an exclusion-only filter", async () => { + // With `!app-a/**` the stale merge delivered only excluded paths, so it + // must be dropped, while the merge that delivered app-b/ is retained. + const result = await getCommitContextsBetweenShas(repo.commits.base, repo.commits.subjectMerge, { + includePaths: ["!app-a/**"], + cwd: repo.cwd, + }); + + const branchNames = result.map((c) => c.branchName).filter((b): b is string => !!b); + expect(branchNames).not.toContain("feat/ABC-1-stale"); + expect(branchNames).toContain("feat/XYZ-2-impl"); + }); + it("still attributes a stale merge to the surface it actually touched", async () => { // The same stale merge DID deliver app-a/ changes, so under an app-a filter // its subject key is correctly retained — the fix discards leaks, not work. diff --git a/src/git.ts b/src/git.ts index db2e2d8..983d049 100644 --- a/src/git.ts +++ b/src/git.ts @@ -6,7 +6,7 @@ import { error as logError, verbose, warn } from "./log"; export function normalizePathspec(pattern: string): string { const trimmed = pattern.trim(); const exclude = trimmed.startsWith("!"); - const path = (exclude ? trimmed.slice(1) : trimmed).replace(/^(\.\/|\/)+/, ""); + const path = (exclude ? trimmed.slice(1) : trimmed).trim().replace(/^(\.\/|\/)+/, ""); return exclude ? `!${path}` : path; } From 3cf0e2b6733a65ae1d3b3e4f0821350da3706d28 Mon Sep 17 00:00:00 2001 From: Axel Niklasson Yun Date: Thu, 20 Aug 2026 11:52:11 +0200 Subject: [PATCH 3/3] Reject a pathless negation instead of scanning unfiltered Co-Authored-By: Claude Fable 5 --- src/git.test.ts | 17 ++++++++++++++--- src/git.ts | 21 +++++++++++++++------ src/provider.ts | 2 +- 3 files changed, 30 insertions(+), 10 deletions(-) diff --git a/src/git.test.ts b/src/git.test.ts index 3eb28d5..57b11d0 100644 --- a/src/git.test.ts +++ b/src/git.test.ts @@ -3,6 +3,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { ConfigurationError } from "./provider"; import { assertGitAvailable, buildPathspecArgs, @@ -95,9 +96,10 @@ describe("buildPathspecArgs", () => { ]); }); - it("should ignore an empty negated pattern", () => { - expect(buildPathspecArgs(["!"])).toEqual([]); - expect(buildPathspecArgs(["! "])).toEqual([]); + it("should reject a negation without a path", () => { + expect(() => buildPathspecArgs(["!"])).toThrow(ConfigurationError); + expect(() => buildPathspecArgs(["! "])).toThrow("a negation must include a path"); + expect(() => buildPathspecArgs(["src/**", "!"])).toThrow(ConfigurationError); }); }); @@ -820,6 +822,15 @@ describe("getCommitContextsBetweenShas", () => { expect(result.map((commit) => commit.sha)).toEqual([repo.commits.third]); }); + it("should reject a negation without a path instead of scanning unfiltered", async () => { + await expect( + getCommitContextsBetweenShas(repo.commits.first, repo.commits.third, { + includePaths: ["!"], + cwd: repo.cwd, + }), + ).rejects.toThrow(ConfigurationError); + }); + 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 983d049..086c83b 100644 --- a/src/git.ts +++ b/src/git.ts @@ -1,4 +1,5 @@ import { execFileSync, execSync, spawn } from "node:child_process"; +import { ConfigurationError } from "./provider"; import type { CommitContext, GitInfo } from "./types"; import { error as logError, verbose, warn } from "./log"; @@ -22,20 +23,28 @@ export function normalizePathspec(pattern: string): string { * Git treats an exclusion-only pathspec as matching everything first, which * lets configurations use `!mobile/**` without also specifying `**`. * + * A negation with no path (`!`) is rejected rather than dropped: silently + * ignoring it would run the scan unfiltered and sweep unrelated commits into + * the release. + * * @see https://git-scm.com/docs/gitglossary#Documentation/gitglossary.txt-aiddefpathspec */ export function buildPathspecArgs(includePaths: string[] | null): string[] { if (!includePaths || includePaths.length === 0) { return []; } - const patterns = includePaths - .map((p) => normalizePathspec(p)) - .filter((p) => p.length > 0 && p !== "!") - .map((p) => (p.startsWith("!") ? `:(top,glob,exclude)${p.slice(1)}` : `:(top,glob)${p}`)); - if (patterns.length === 0) { + const patterns = includePaths.map((p) => normalizePathspec(p)).filter((p) => p.length > 0); + if (patterns.includes("!")) { + throw new ConfigurationError( + 'Invalid path filter "!": a negation must include a path (e.g. "!mobile/**")', + "invalid-path-filter", + ); + } + const pathspecs = patterns.map((p) => (p.startsWith("!") ? `:(top,glob,exclude)${p.slice(1)}` : `:(top,glob)${p}`)); + if (pathspecs.length === 0) { return []; } - return ["--", ...patterns]; + return ["--", ...pathspecs]; } /** diff --git a/src/provider.ts b/src/provider.ts index 393b6e3..a5e3c88 100644 --- a/src/provider.ts +++ b/src/provider.ts @@ -3,7 +3,7 @@ import type { RepoInfo, RepositoryProvider, ResolvedRepoInfo } from "./types"; export class ConfigurationError extends Error { constructor( message: string, - readonly code: "invalid-provider-override" | "unknown-provider", + readonly code: "invalid-provider-override" | "unknown-provider" | "invalid-path-filter", ) { super(message); this.name = "ConfigurationError";