Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
66 changes: 66 additions & 0 deletions src/git.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -38,6 +39,16 @@ 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 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("");
});
Expand Down Expand Up @@ -75,6 +86,21 @@ 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 reject a negation without a path", () => {
expect(() => buildPathspecArgs(["!"])).toThrow(ConfigurationError);
expect(() => buildPathspecArgs(["! "])).toThrow("a negation must include a path");
expect(() => buildPathspecArgs(["src/**", "!"])).toThrow(ConfigurationError);
});
});

describe("extractBranchName", () => {
Expand Down Expand Up @@ -778,6 +804,33 @@ 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 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/**)
Expand Down Expand Up @@ -1146,6 +1199,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.
Expand Down
37 changes: 27 additions & 10 deletions src/git.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,50 @@
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";

/** 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).trim().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 `**`.
*
* 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)
.map((p) => `:(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];
}

/**
Expand Down
2 changes: 1 addition & 1 deletion src/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Loading