Skip to content

Commit d64aefc

Browse files
Linearlinear-code[bot]
andcommitted
Support negated release path filters
Co-authored-by: linear-code[bot] <222613912+linear-code[bot]@users.noreply.github.com>
1 parent cd53782 commit d64aefc

3 files changed

Lines changed: 54 additions & 7 deletions

File tree

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,9 +215,12 @@ linear-release sync --include-paths="apps/mobile/**"
215215

216216
# Multiple patterns
217217
linear-release sync --include-paths="apps/mobile/**,packages/shared/**"
218+
219+
# Include everything except mobile and desktop apps
220+
linear-release sync --include-paths='!apps/mobile/**,!apps/desktop/**'
218221
```
219222

220-
Patterns use [Git pathspec](https://git-scm.com/docs/gitglossary#Documentation/gitglossary.txt-pathspec) glob syntax. Paths are relative to the repository root.
223+
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.
221224

222225
Path patterns can also be configured in your pipeline settings in Linear. If both are set, the CLI `--include-paths` option takes precedence.
223226

src/git.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,11 @@ describe("normalizePathspec", () => {
3838
expect(normalizePathspec(" android/** ")).toBe("android/**");
3939
});
4040

41+
it("should preserve negation while normalizing the path", () => {
42+
expect(normalizePathspec(" !./mobile/** ")).toBe("!mobile/**");
43+
expect(normalizePathspec("!/desktop/**")).toBe("!desktop/**");
44+
});
45+
4146
it("should handle empty strings", () => {
4247
expect(normalizePathspec("")).toBe("");
4348
});
@@ -75,6 +80,19 @@ describe("buildPathspecArgs", () => {
7580
":(top,glob)ios/**",
7681
]);
7782
});
83+
84+
it("should build exclude pathspecs for negated patterns", () => {
85+
expect(buildPathspecArgs(["**", "!./mobile/**", " !/desktop/** "])).toEqual([
86+
"--",
87+
":(top,glob)**",
88+
":(top,glob,exclude)mobile/**",
89+
":(top,glob,exclude)desktop/**",
90+
]);
91+
});
92+
93+
it("should ignore an empty negated pattern", () => {
94+
expect(buildPathspecArgs(["!"])).toEqual([]);
95+
});
7896
});
7997

8098
describe("extractBranchName", () => {
@@ -778,6 +796,24 @@ describe("getCommitContextsBetweenShas", () => {
778796
expect(withGithubFilter[0]?.sha).toBe(repo.commits.second);
779797
});
780798

799+
it("should exclude commits matching negated path patterns", async () => {
800+
const result = await getCommitContextsBetweenShas(repo.commits.first, repo.commits.third, {
801+
includePaths: ["**", "!.github/**"],
802+
cwd: repo.cwd,
803+
});
804+
805+
expect(result.map((commit) => commit.sha)).toEqual([repo.commits.third]);
806+
});
807+
808+
it("should support exclusion-only path patterns", async () => {
809+
const result = await getCommitContextsBetweenShas(repo.commits.first, repo.commits.third, {
810+
includePaths: ["!.github/**"],
811+
cwd: repo.cwd,
812+
});
813+
814+
expect(result.map((commit) => commit.sha)).toEqual([repo.commits.third]);
815+
});
816+
781817
it("should resolve paths relative to repo root even when process.cwd() is a subdirectory", async () => {
782818
// Simulates running the CLI from a subdirectory (e.g., mobile-ios/ci_scripts)
783819
// while using paths relative to the repo root (e.g., src/**)

src/git.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,25 @@ import { execFileSync, execSync, spawn } from "node:child_process";
22
import type { CommitContext, GitInfo } from "./types";
33
import { error as logError, verbose, warn } from "./log";
44

5-
/** Strips leading "./" or "/" so paths are clean for git pathspec. */
5+
/** Preserves a leading "!" while cleaning the path for use as a git pathspec. */
66
export function normalizePathspec(pattern: string): string {
7-
return pattern.replace(/^(\.\/|\/)+/, "").trim();
7+
const trimmed = pattern.trim();
8+
const exclude = trimmed.startsWith("!");
9+
const path = (exclude ? trimmed.slice(1) : trimmed).replace(/^(\.\/|\/)+/, "");
10+
return exclude ? `!${path}` : path;
811
}
912

1013
/**
11-
* Builds git pathspec arguments from include patterns.
14+
* Builds git pathspec arguments from include and exclude patterns.
1215
*
13-
* Uses `:(top,glob)` pathspec prefix:
16+
* Uses `:(top,glob)` pathspec prefix for includes and
17+
* `:(top,glob,exclude)` for patterns prefixed with `!`:
1418
* - `top`: paths are relative to repo root, not the current working directory
1519
* - `glob`: enables `**` for recursive matching (e.g., "src/**")
20+
* - `exclude`: removes matching paths after positive pathspecs are resolved
21+
*
22+
* Git treats an exclusion-only pathspec as matching everything first, which
23+
* lets configurations use `!mobile/**` without also specifying `**`.
1624
*
1725
* @see https://git-scm.com/docs/gitglossary#Documentation/gitglossary.txt-aiddefpathspec
1826
*/
@@ -22,8 +30,8 @@ export function buildPathspecArgs(includePaths: string[] | null): string[] {
2230
}
2331
const patterns = includePaths
2432
.map((p) => normalizePathspec(p))
25-
.filter((p) => p.length > 0)
26-
.map((p) => `:(top,glob)${p}`);
33+
.filter((p) => p.length > 0 && p !== "!")
34+
.map((p) => (p.startsWith("!") ? `:(top,glob,exclude)${p.slice(1)}` : `:(top,glob)${p}`));
2735
if (patterns.length === 0) {
2836
return [];
2937
}

0 commit comments

Comments
 (0)