Skip to content
Open
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
36 changes: 36 additions & 0 deletions src/git.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("");
});
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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/**)
Expand Down
20 changes: 14 additions & 6 deletions src/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -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 [];
}
Expand Down
Loading