Skip to content

Commit 634acc7

Browse files
linear-code[bot]Linearaxelniklassonclaude
authored
Support negated release path filters (#127)
* Support negated release path filters Co-authored-by: linear-code[bot] <222613912+linear-code[bot]@users.noreply.github.com> * Trim whitespace after negation so the exclusion is not silently dropped Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Reject a pathless negation instead of scanning unfiltered Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Linear <linear-6ee3ee0f-1ea6-4b88-a969-f63432eeb657@linear.linear.app> Co-authored-by: linear-code[bot] <222613912+linear-code[bot]@users.noreply.github.com> Co-authored-by: Axel Niklasson Yun <axel@linear.app> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent cd53782 commit 634acc7

4 files changed

Lines changed: 98 additions & 12 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: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
33
import { tmpdir } from "node:os";
44
import { join } from "node:path";
55
import { afterAll, beforeAll, describe, expect, it } from "vitest";
6+
import { ConfigurationError } from "./provider";
67
import {
78
assertGitAvailable,
89
buildPathspecArgs,
@@ -38,6 +39,16 @@ describe("normalizePathspec", () => {
3839
expect(normalizePathspec(" android/** ")).toBe("android/**");
3940
});
4041

42+
it("should preserve negation while normalizing the path", () => {
43+
expect(normalizePathspec(" !./mobile/** ")).toBe("!mobile/**");
44+
expect(normalizePathspec("!/desktop/**")).toBe("!desktop/**");
45+
});
46+
47+
it("should trim whitespace between the negation and the path", () => {
48+
expect(normalizePathspec("! mobile/**")).toBe("!mobile/**");
49+
expect(normalizePathspec("! ./desktop/**")).toBe("!desktop/**");
50+
});
51+
4152
it("should handle empty strings", () => {
4253
expect(normalizePathspec("")).toBe("");
4354
});
@@ -75,6 +86,21 @@ describe("buildPathspecArgs", () => {
7586
":(top,glob)ios/**",
7687
]);
7788
});
89+
90+
it("should build exclude pathspecs for negated patterns", () => {
91+
expect(buildPathspecArgs(["**", "!./mobile/**", " !/desktop/** "])).toEqual([
92+
"--",
93+
":(top,glob)**",
94+
":(top,glob,exclude)mobile/**",
95+
":(top,glob,exclude)desktop/**",
96+
]);
97+
});
98+
99+
it("should reject a negation without a path", () => {
100+
expect(() => buildPathspecArgs(["!"])).toThrow(ConfigurationError);
101+
expect(() => buildPathspecArgs(["! "])).toThrow("a negation must include a path");
102+
expect(() => buildPathspecArgs(["src/**", "!"])).toThrow(ConfigurationError);
103+
});
78104
});
79105

80106
describe("extractBranchName", () => {
@@ -778,6 +804,33 @@ describe("getCommitContextsBetweenShas", () => {
778804
expect(withGithubFilter[0]?.sha).toBe(repo.commits.second);
779805
});
780806

807+
it("should exclude commits matching negated path patterns", async () => {
808+
const result = await getCommitContextsBetweenShas(repo.commits.first, repo.commits.third, {
809+
includePaths: ["**", "!.github/**"],
810+
cwd: repo.cwd,
811+
});
812+
813+
expect(result.map((commit) => commit.sha)).toEqual([repo.commits.third]);
814+
});
815+
816+
it("should support exclusion-only path patterns", async () => {
817+
const result = await getCommitContextsBetweenShas(repo.commits.first, repo.commits.third, {
818+
includePaths: ["!.github/**"],
819+
cwd: repo.cwd,
820+
});
821+
822+
expect(result.map((commit) => commit.sha)).toEqual([repo.commits.third]);
823+
});
824+
825+
it("should reject a negation without a path instead of scanning unfiltered", async () => {
826+
await expect(
827+
getCommitContextsBetweenShas(repo.commits.first, repo.commits.third, {
828+
includePaths: ["!"],
829+
cwd: repo.cwd,
830+
}),
831+
).rejects.toThrow(ConfigurationError);
832+
});
833+
781834
it("should resolve paths relative to repo root even when process.cwd() is a subdirectory", async () => {
782835
// Simulates running the CLI from a subdirectory (e.g., mobile-ios/ci_scripts)
783836
// while using paths relative to the repo root (e.g., src/**)
@@ -1146,6 +1199,19 @@ describe("merge commit handling", () => {
11461199
expect(branchNames).toContain("feat/XYZ-2-impl");
11471200
});
11481201

1202+
it("applies merge retention under an exclusion-only filter", async () => {
1203+
// With `!app-a/**` the stale merge delivered only excluded paths, so it
1204+
// must be dropped, while the merge that delivered app-b/ is retained.
1205+
const result = await getCommitContextsBetweenShas(repo.commits.base, repo.commits.subjectMerge, {
1206+
includePaths: ["!app-a/**"],
1207+
cwd: repo.cwd,
1208+
});
1209+
1210+
const branchNames = result.map((c) => c.branchName).filter((b): b is string => !!b);
1211+
expect(branchNames).not.toContain("feat/ABC-1-stale");
1212+
expect(branchNames).toContain("feat/XYZ-2-impl");
1213+
});
1214+
11491215
it("still attributes a stale merge to the surface it actually touched", async () => {
11501216
// The same stale merge DID deliver app-a/ changes, so under an app-a filter
11511217
// its subject key is correctly retained — the fix discards leaks, not work.

src/git.ts

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,50 @@
11
import { execFileSync, execSync, spawn } from "node:child_process";
2+
import { ConfigurationError } from "./provider";
23
import type { CommitContext, GitInfo } from "./types";
34
import { error as logError, verbose, warn } from "./log";
45

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

1014
/**
11-
* Builds git pathspec arguments from include patterns.
15+
* Builds git pathspec arguments from include and exclude patterns.
1216
*
13-
* Uses `:(top,glob)` pathspec prefix:
17+
* Uses `:(top,glob)` pathspec prefix for includes and
18+
* `:(top,glob,exclude)` for patterns prefixed with `!`:
1419
* - `top`: paths are relative to repo root, not the current working directory
1520
* - `glob`: enables `**` for recursive matching (e.g., "src/**")
21+
* - `exclude`: removes matching paths after positive pathspecs are resolved
22+
*
23+
* Git treats an exclusion-only pathspec as matching everything first, which
24+
* lets configurations use `!mobile/**` without also specifying `**`.
25+
*
26+
* A negation with no path (`!`) is rejected rather than dropped: silently
27+
* ignoring it would run the scan unfiltered and sweep unrelated commits into
28+
* the release.
1629
*
1730
* @see https://git-scm.com/docs/gitglossary#Documentation/gitglossary.txt-aiddefpathspec
1831
*/
1932
export function buildPathspecArgs(includePaths: string[] | null): string[] {
2033
if (!includePaths || includePaths.length === 0) {
2134
return [];
2235
}
23-
const patterns = includePaths
24-
.map((p) => normalizePathspec(p))
25-
.filter((p) => p.length > 0)
26-
.map((p) => `:(top,glob)${p}`);
27-
if (patterns.length === 0) {
36+
const patterns = includePaths.map((p) => normalizePathspec(p)).filter((p) => p.length > 0);
37+
if (patterns.includes("!")) {
38+
throw new ConfigurationError(
39+
'Invalid path filter "!": a negation must include a path (e.g. "!mobile/**")',
40+
"invalid-path-filter",
41+
);
42+
}
43+
const pathspecs = patterns.map((p) => (p.startsWith("!") ? `:(top,glob,exclude)${p.slice(1)}` : `:(top,glob)${p}`));
44+
if (pathspecs.length === 0) {
2845
return [];
2946
}
30-
return ["--", ...patterns];
47+
return ["--", ...pathspecs];
3148
}
3249

3350
/**

src/provider.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { RepoInfo, RepositoryProvider, ResolvedRepoInfo } from "./types";
33
export class ConfigurationError extends Error {
44
constructor(
55
message: string,
6-
readonly code: "invalid-provider-override" | "unknown-provider",
6+
readonly code: "invalid-provider-override" | "unknown-provider" | "invalid-path-filter",
77
) {
88
super(message);
99
this.name = "ConfigurationError";

0 commit comments

Comments
 (0)