Fix wildcard src input

This commit is contained in:
Kevin Stillhammer
2026-04-16 20:28:26 +02:00
parent a9cfed68e4
commit 2d140ca49e
16 changed files with 3803 additions and 507 deletions
+30
View File
@@ -395,6 +395,34 @@ jobs:
fi
env:
CHECK_SHOULD_FAIL_OUTCOME: ${{ steps.check-should-fail.outcome }}
test-glob-src:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ ubuntu-latest, windows-latest ]
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Use glob pattern in src
uses: ./
with:
src: __tests__/fixtures/glob-project/**/*.py
test-glob-single-star-src:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ ubuntu-latest, windows-latest ]
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Use single-star glob pattern in src
uses: ./
with:
src: __tests__/fixtures/glob-single-star-project/foo/bar/*.py
test-multiple-src:
runs-on: ${{ matrix.os }}
strategy:
@@ -479,6 +507,8 @@ jobs:
- test-with-explicit-token
- test-args
- test-failure
- test-glob-src
- test-glob-single-star-src
- test-multiple-src
- test-parent-directory-pyproject
- test-custom-manifest-file
+28 -1
View File
@@ -33,7 +33,7 @@ anything `ruff` can (ex, fix).
| `version-file` | The file to read the version from. See [Install a version from a specified version file](#install-a-version-from-a-specified-version-file) | None |
| `manifest-file` | URL to a custom Ruff manifest in the `astral-sh/versions` format. | None |
| `args` | The arguments to pass to the `ruff` command. See [Configuring Ruff] | `check` |
| `src` | The directory or single files to run `ruff` on. | [github.workspace] |
| `src` | Source path(s) to run `ruff` on. Supports glob patterns. | [github.workspace] |
| `checksum` | The sha256 checksum of the downloaded artifact. | None |
| `github-token` | The GitHub token to use when downloading Ruff release artifacts from GitHub. | `GITHUB_TOKEN` |
@@ -56,6 +56,8 @@ By default, Ruff version metadata is resolved from the
### Specify multiple files
Separate multiple `src` values with whitespace.
```yaml
- uses: astral-sh/ruff-action@v3
with:
@@ -64,6 +66,31 @@ By default, Ruff version metadata is resolved from the
path/to/file2.py
```
### Use glob patterns
Glob patterns (`*`, `?`, `[...]`, and `**`) are expanded by the action using
[`@actions/glob`](https://github.com/actions/toolkit/tree/main/packages/glob),
so they work consistently across Linux, macOS, and Windows runners. This action
does not emulate Bash, PowerShell, or other shell-specific glob expansion.
Hidden files and directories are skipped by default; target them explicitly, for
example with `.venv/**/*.py`, to include them. If a pattern does not match any
files, the original pattern is passed to Ruff so Ruff can report the missing
path. Literal glob metacharacters in file or directory names must be escaped
using `@actions/glob` syntax.
```yaml
- uses: astral-sh/ruff-action@v3
with:
src: "src/**/*.py"
```
> [!NOTE]
> When using multiple patterns, only the first is used to search for
> `pyproject.toml` to determine the Ruff version. Use the `version` input
> for explicit control. Large glob expansions may hit command-line length limits,
> especially on Windows.
### Use to install ruff
This action adds ruff to the PATH, so you can use it in subsequent steps.
@@ -0,0 +1,13 @@
[project]
name = "pyython-project"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"ruff==0.6.2",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
@@ -0,0 +1 @@
print("hello")
@@ -0,0 +1 @@
print("deeper")
@@ -0,0 +1 @@
print("check me")
@@ -0,0 +1,2 @@
# This file should be ignored when using foo/bar/*.py glob
print("ignore me")
@@ -0,0 +1,13 @@
[project]
name = "glob-single-star-project"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"ruff==0.6.2",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
+374
View File
@@ -0,0 +1,374 @@
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { afterEach, describe, expect, it, jest } from "@jest/globals";
jest.unstable_mockModule("@actions/core", () => ({
debug: jest.fn(),
info: jest.fn(),
}));
const {
expandSourceInput,
getSourceBasePath,
getVersionSourceDirectory,
splitInput,
} = await import("../../src/utils/source-input");
const { findPyprojectToml } = await import("../../src/utils/pyproject-finder");
let tempDir: string | undefined;
afterEach(async () => {
if (tempDir !== undefined) {
await fs.rm(tempDir, { force: true, recursive: true });
tempDir = undefined;
}
});
async function createTempProject(): Promise<string> {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "ruff-action-source-"));
await fs.mkdir(path.join(tempDir, "src", "package", "nested"), {
recursive: true,
});
await fs.writeFile(path.join(tempDir, "src", "package", "__init__.py"), "");
await fs.writeFile(path.join(tempDir, "src", "package", "main.py"), "");
await fs.writeFile(
path.join(tempDir, "src", "package", "nested", "mod.py"),
"",
);
await fs.writeFile(path.join(tempDir, "src", "package", "notes.txt"), "");
await fs.mkdir(path.join(tempDir, "src", "pkg[legacy]"), {
recursive: true,
});
await fs.writeFile(path.join(tempDir, "src", "pkg[legacy]", "main.py"), "");
await fs.mkdir(path.join(tempDir, "src", "pkgl"), { recursive: true });
await fs.writeFile(path.join(tempDir, "src", "pkgl", "main.py"), "");
return tempDir;
}
describe("splitInput", () => {
it("splits whitespace-delimited inputs", () => {
expect(splitInput(" check --fix\n--diff ")).toEqual([
"check",
"--fix",
"--diff",
]);
});
it("returns an empty array for empty input", () => {
expect(splitInput(" \n\t ")).toEqual([]);
});
});
describe("expandSourceInput", () => {
it("leaves non-glob sources unchanged", async () => {
await expect(expandSourceInput("src file.py")).resolves.toEqual([
"src",
"file.py",
]);
});
it("expands recursive globs without relying on shell behavior", async () => {
const projectDir = await createTempProject();
const pattern = path.join(projectDir, "src", "**", "*.py");
await expect(expandSourceInput(pattern)).resolves.toEqual([
path.join(projectDir, "src", "package", "__init__.py"),
path.join(projectDir, "src", "package", "main.py"),
path.join(projectDir, "src", "package", "nested", "mod.py"),
path.join(projectDir, "src", "pkg[legacy]", "main.py"),
path.join(projectDir, "src", "pkgl", "main.py"),
]);
});
it("preserves relative source patterns as relative paths", async () => {
const projectDir = await createTempProject();
const originalCwd = process.cwd();
try {
process.chdir(projectDir);
await expect(expandSourceInput("src/**/*.py")).resolves.toEqual([
path.join("src", "package", "__init__.py"),
path.join("src", "package", "main.py"),
path.join("src", "package", "nested", "mod.py"),
path.join("src", "pkg[legacy]", "main.py"),
path.join("src", "pkgl", "main.py"),
]);
} finally {
process.chdir(originalCwd);
}
});
it("maps relative root glob matches to . instead of an empty argument", async () => {
const projectDir = await createTempProject();
const originalCwd = process.cwd();
try {
process.chdir(projectDir);
for (const pattern of ["**", "**/", "./**"]) {
const sources = await expandSourceInput(pattern);
expect(sources).toContain(".");
expect(sources).not.toContain("");
}
} finally {
process.chdir(originalCwd);
}
});
it("excludes hidden paths by default while allowing explicit hidden patterns", async () => {
const projectDir = await createTempProject();
const originalCwd = process.cwd();
await fs.mkdir(path.join(projectDir, ".venv"));
await fs.writeFile(path.join(projectDir, ".venv", "ignored.py"), "");
await fs.mkdir(path.join(projectDir, ".tox"));
await fs.writeFile(path.join(projectDir, ".tox", "ignored.py"), "");
try {
process.chdir(projectDir);
const defaultSources = await expandSourceInput("**/*.py");
expect(defaultSources).not.toContain(path.join(".venv", "ignored.py"));
expect(defaultSources).not.toContain(path.join(".tox", "ignored.py"));
await expect(expandSourceInput(".venv/**/*.py")).resolves.toEqual([
path.join(".venv", "ignored.py"),
]);
} finally {
process.chdir(originalCwd);
}
});
it("preserves unmatched globs for Ruff to report", async () => {
const projectDir = await createTempProject();
const pattern = path.join(projectDir, "src", "**", "missing-*.py");
await expect(expandSourceInput(pattern)).resolves.toEqual([pattern]);
});
it("expands multiple sources in input order", async () => {
const projectDir = await createTempProject();
const first = path.join(projectDir, "src", "package", "main.py");
const second = path.join(projectDir, "src", "package", "nested", "*.py");
await expect(expandSourceInput(`${first} ${second}`)).resolves.toEqual([
first,
path.join(projectDir, "src", "package", "nested", "mod.py"),
]);
});
it("uses @actions/glob syntax for paths that contain glob metacharacters", async () => {
const projectDir = await createTempProject();
await expect(
expandSourceInput(path.join(projectDir, "src", "pkg[legacy]")),
).resolves.toEqual([path.join(projectDir, "src", "pkgl")]);
await expect(
expandSourceInput(path.join(projectDir, "src", "pkg[legacy]", "*.py")),
).resolves.toEqual([path.join(projectDir, "src", "pkgl", "main.py")]);
});
it("supports @actions/glob escaping for literal glob metacharacters", async () => {
const projectDir = await createTempProject();
const pattern = path.join(projectDir, "src", "pkg[[]legacy]", "*.py");
await expect(expandSourceInput(pattern)).resolves.toEqual([
path.join(projectDir, "src", "pkg[legacy]", "main.py"),
]);
});
it("does not traverse symlinked directories", async () => {
const projectDir = await createTempProject();
const externalDir = path.join(projectDir, "external");
const symlinkPath = path.join(projectDir, "src", "linked");
await fs.mkdir(externalDir);
await fs.writeFile(path.join(externalDir, "external.py"), "");
await fs.symlink(externalDir, symlinkPath, "dir");
const sources = await expandSourceInput(
path.join(projectDir, "src", "**", "*.py"),
);
expect(sources).not.toContain(path.join(symlinkPath, "external.py"));
});
it("still expands missing paths that contain glob metacharacters", async () => {
const projectDir = await createTempProject();
const pattern = path.join(projectDir, "src", "pkg[l]");
await expect(expandSourceInput(pattern)).resolves.toEqual([
path.join(projectDir, "src", "pkgl"),
]);
});
});
describe("getVersionSourceDirectory", () => {
it("does not scan src globs when version input is explicit", async () => {
await expect(
getVersionSourceDirectory("../**/*.py", "0.1.0", undefined),
).resolves.toBe(".");
});
it("does not scan src globs when version-file input is explicit", async () => {
await expect(
getVersionSourceDirectory("../**/*.py", undefined, "pyproject.toml"),
).resolves.toBe(".");
});
it("uses source discovery when no version input is explicit", async () => {
await expect(
getVersionSourceDirectory("../**/*.py", undefined, undefined),
).rejects.toThrow("Invalid pattern '../**/*.py'");
});
});
describe("getSourceBasePath", () => {
it("returns the directory for a plain path", async () => {
await expect(getSourceBasePath("my-project")).resolves.toBe("my-project");
});
it("returns the directory for a dotted path", async () => {
await expect(getSourceBasePath("./src")).resolves.toBe("./src");
});
it("preserves filesystem root paths", async () => {
const root = path.parse(process.cwd()).root;
await expect(getSourceBasePath(root)).resolves.toBe(root);
});
it("strips glob pattern and returns parent directory", async () => {
await expect(getSourceBasePath("src/**/*.py")).resolves.toBe("src");
});
it("returns . for a top-level glob", async () => {
await expect(getSourceBasePath("*.py")).resolves.toBe(".");
});
it("returns . for empty string", async () => {
await expect(getSourceBasePath("")).resolves.toBe(".");
});
it("uses only the first whitespace-delimited token", async () => {
await expect(getSourceBasePath("sub1/*.py sub2/")).resolves.toBe("sub1");
});
it("handles trailing slash in prefix", async () => {
await expect(getSourceBasePath("sub/*.py")).resolves.toBe("sub");
});
it("uses the parent directory when a glob appears in a filename", async () => {
await expect(getSourceBasePath("src/file*.py")).resolves.toBe("src");
});
it("handles ? glob", async () => {
await expect(getSourceBasePath("file?.py")).resolves.toBe(".");
});
it("handles bracket glob", async () => {
await expect(getSourceBasePath("file[0-9].py")).resolves.toBe(".");
});
it("uses @actions/glob syntax for base paths with glob metacharacters", async () => {
const projectDir = await createTempProject();
const originalCwd = process.cwd();
try {
process.chdir(projectDir);
await expect(getSourceBasePath("src/pkg[legacy]")).resolves.toBe(
path.join("src", "pkgl"),
);
await expect(getSourceBasePath("src/pkg[legacy]/*.py")).resolves.toBe(
path.join("src", "pkgl", "main.py"),
);
} finally {
process.chdir(originalCwd);
}
});
it("supports @actions/glob escaping for version discovery from literal glob metacharacters", async () => {
const projectDir = await createTempProject();
const originalCwd = process.cwd();
const pyprojectPath = path.join(
projectDir,
"src",
"pkg[legacy]",
"pyproject.toml",
);
await fs.writeFile(
pyprojectPath,
"[project]\ndependencies = ['ruff==0.6.2']\n",
);
try {
process.chdir(projectDir);
const sourceBasePath = await getSourceBasePath(
"src/pkg[[]legacy]/main.py",
);
expect(findPyprojectToml(sourceBasePath, projectDir)).toBe(
await fs.realpath(pyprojectPath),
);
} finally {
process.chdir(originalCwd);
}
});
it("uses the first expanded glob match for version discovery", async () => {
const projectDir = await createTempProject();
const originalCwd = process.cwd();
const pyprojectPath = path.join(
projectDir,
"packages",
"foo",
"pyproject.toml",
);
await fs.mkdir(path.join(projectDir, "packages", "foo", "src"), {
recursive: true,
});
await fs.writeFile(
pyprojectPath,
"[project]\ndependencies = ['ruff==0.10.0']\n",
);
await fs.writeFile(
path.join(projectDir, "packages", "foo", "src", "module.py"),
"",
);
try {
process.chdir(projectDir);
const sourceBasePath = await getSourceBasePath("packages/*/src/**/*.py");
expect(sourceBasePath).toBe(
path.join("packages", "foo", "src", "module.py"),
);
const workspaceRoot = await fs.realpath(projectDir);
expect(findPyprojectToml(sourceBasePath, workspaceRoot)).toBe(
await fs.realpath(pyprojectPath),
);
} finally {
process.chdir(originalCwd);
}
});
it("uses the unmatched glob fallback when version-discovery glob has no matches", async () => {
const projectDir = await createTempProject();
const originalCwd = process.cwd();
await fs.mkdir(path.join(projectDir, "packages"));
try {
process.chdir(projectDir);
await expect(getSourceBasePath("packages/*/src/**/*.py")).resolves.toBe(
"packages",
);
} finally {
process.chdir(originalCwd);
}
});
it("does not treat brace expansion as glob syntax", async () => {
await expect(getSourceBasePath("src/{a,b}/*.py")).resolves.toBe(
"src/{a,b}",
);
});
});
+1 -1
View File
@@ -7,7 +7,7 @@ inputs:
required: false
default: "check"
src:
description: "Source to run Ruff. Defaults to the current directory."
description: "Source path(s) or @actions/glob pattern(s) to run Ruff on. Defaults to the current workspace."
required: false
default: ${{ github.workspace }}
version:
Generated Vendored
+3096 -492
View File
File diff suppressed because it is too large Load Diff
+47
View File
@@ -11,6 +11,7 @@
"dependencies": {
"@actions/core": "^3.0.0",
"@actions/exec": "^3.0.0",
"@actions/glob": "^0.7.0",
"@actions/tool-cache": "^4.0.0",
"@octokit/core": "^7.0.3",
"@octokit/plugin-paginate-rest": "^13.1.1",
@@ -51,6 +52,52 @@
"@actions/io": "^3.0.2"
}
},
"node_modules/@actions/glob": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/@actions/glob/-/glob-0.7.0.tgz",
"integrity": "sha512-+7s3wM+cXapDLmLL1NVWHawqcJOZzXZy2df/VhNn8DnZtS/x83iTCKaUn9F0llur4h3CII0AilvKKH4CMPL8Gw==",
"license": "MIT",
"dependencies": {
"@actions/core": "^3.0.0",
"minimatch": "^10.2.5"
}
},
"node_modules/@actions/glob/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/@actions/glob/node_modules/brace-expansion": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/@actions/glob/node_modules/minimatch": {
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.5"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@actions/http-client": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-4.0.0.tgz",
+1
View File
@@ -30,6 +30,7 @@
"dependencies": {
"@actions/core": "^3.0.0",
"@actions/exec": "^3.0.0",
"@actions/glob": "^0.7.0",
"@actions/tool-cache": "^4.0.0",
"@octokit/core": "^7.0.3",
"@octokit/plugin-paginate-rest": "^13.1.1",
+14 -9
View File
@@ -21,6 +21,11 @@ import {
getPlatform,
type Platform,
} from "./utils/platforms";
import {
expandSourceInput,
getVersionSourceDirectory,
splitInput,
} from "./utils/source-input";
import { resolveRuffVersion } from "./version/resolve";
async function run(): Promise<void> {
@@ -42,11 +47,7 @@ async function run(): Promise<void> {
core.setOutput("ruff-version", setupResult.version);
core.info(`Successfully installed ruff version ${setupResult.version}`);
await runRuff(
path.join(setupResult.ruffDir, "ruff"),
args.split(" "),
src.split(" "),
);
await runRuff(path.join(setupResult.ruffDir, "ruff"), args, src);
process.exit(0);
} catch (err) {
@@ -94,7 +95,11 @@ async function setupRuff(
async function determineVersion(): Promise<string> {
return await resolveRuffVersion({
manifestFile: manifestFile || undefined,
sourceDirectory: src,
sourceDirectory: await getVersionSourceDirectory(
src,
version,
versionFileInput,
),
version,
versionFile: versionFileInput,
workspaceRoot: process.env.GITHUB_WORKSPACE || ".",
@@ -125,10 +130,10 @@ function getActionRoot(): string {
async function runRuff(
ruffExecutablePath: string,
args: string[],
src: string[],
args: string,
src: string,
): Promise<void> {
const execArgs = [...args, ...src];
const execArgs = [...splitInput(args), ...(await expandSourceInput(src))];
await exec.exec(ruffExecutablePath, execArgs);
}
+20 -4
View File
@@ -3,19 +3,22 @@ import * as path from "node:path";
import * as core from "@actions/core";
/**
* Search for a pyproject.toml file starting from the given directory
* Search for a pyproject.toml file starting from the given source path
* and traversing upwards through parent directories until reaching
* the GitHub workspace root.
*
* @param startDir The directory to start the search from (e.g., the src input)
* If the source path points to a file, the search begins in that file's
* parent directory.
*
* @param startPath The source path to start the search from (file or directory)
* @param workspaceRoot The GitHub workspace directory (GITHUB_WORKSPACE)
* @returns The path to the found pyproject.toml, or undefined if not found
*/
export function findPyprojectToml(
startDir: string,
startPath: string,
workspaceRoot: string,
): string | undefined {
let currentDir = path.resolve(startDir);
let currentDir = resolveStartDirectory(startPath);
const resolvedWorkspaceRoot = path.resolve(workspaceRoot);
while (true) {
@@ -42,6 +45,19 @@ export function findPyprojectToml(
}
}
function resolveStartDirectory(startPath: string): string {
const resolvedStartPath = path.resolve(startPath);
if (!fs.existsSync(resolvedStartPath)) {
return resolvedStartPath;
}
const stats = fs.statSync(resolvedStartPath);
return stats.isDirectory()
? resolvedStartPath
: path.dirname(resolvedStartPath);
}
/**
* Check if a given path is within or equal to the workspace root.
*
+161
View File
@@ -0,0 +1,161 @@
import * as fs from "node:fs/promises";
import * as path from "node:path";
import * as glob from "@actions/glob";
const GLOB_PATTERN = /[*?[]/;
/**
* Split an action input into whitespace-delimited tokens.
*
* The action has historically documented both `args` and `src` as plain
* whitespace-delimited inputs. Keep that behavior instead of invoking a shell.
*/
export function splitInput(input: string): string[] {
return input.trim().split(/\s+/).filter(Boolean);
}
/**
* Expand glob patterns from the `src` input in a cross-platform way.
*
* Glob syntax is defined by @actions/glob. This action does not emulate shell
* expansion. Unmatched patterns are preserved so Ruff can report the missing
* path.
*/
export async function expandSourceInput(srcInput: string): Promise<string[]> {
const sources = splitInput(srcInput);
if (sources.length === 0) {
return [];
}
const expandedSources: string[] = [];
for (const source of sources) {
if (!hasGlobPattern(source)) {
expandedSources.push(source);
continue;
}
const matches = await globMatches(source);
expandedSources.push(...(matches.length > 0 ? matches : [source]));
}
return expandedSources;
}
/**
* Resolve the starting path for version discovery from the `src` input.
*
* Takes the first whitespace-delimited token. Literal paths are preserved. Glob
* patterns use their first sorted match when available, so pyproject.toml
* discovery starts from a path Ruff will actually check. Unmatched globs fall
* back to @actions/glob's search path.
*/
export async function getVersionSourceDirectory(
srcInput: string,
versionInput: string | undefined,
versionFileInput: string | undefined,
): Promise<string> {
if (hasInput(versionInput) || hasInput(versionFileInput)) {
return ".";
}
return await getSourceBasePath(srcInput);
}
export async function getSourceBasePath(srcInput: string): Promise<string> {
const firstSource = splitInput(srcInput)[0] ?? ".";
if (!hasGlobPattern(firstSource)) {
return stripTrailingSeparators(firstSource);
}
const globber = await createGlobber(firstSource);
const matches = (await globber.glob()).sort();
if (matches.length > 0) {
return (
(await preserveSourcePathStyle(firstSource, [matches[0] ?? "."]))[0] ??
"."
);
}
const searchPaths = globber.getSearchPaths().sort();
if (searchPaths.length > 0) {
return (
(
await preserveSourcePathStyle(firstSource, [searchPaths[0] ?? "."])
)[0] ?? "."
);
}
return ".";
}
function hasInput(input: string | undefined): boolean {
return input !== undefined && input.trim() !== "";
}
function hasGlobPattern(source: string): boolean {
return GLOB_PATTERN.test(source);
}
function stripTrailingSeparators(source: string): string {
const root = path.parse(source).root;
const strippedSource = source.replace(/[/\\]+$/, "");
if (root !== "" && strippedSource.length < root.length) {
return root;
}
return strippedSource || root || ".";
}
async function globMatches(pattern: string): Promise<string[]> {
const globber = await createGlobber(pattern);
const matches = await globber.glob();
return await preserveSourcePathStyle(pattern, matches.sort());
}
async function createGlobber(pattern: string): Promise<glob.Globber> {
return await glob.create(normalizeGlobPattern(pattern), {
excludeHiddenFiles: !explicitlyTargetsHiddenPath(pattern),
followSymbolicLinks: false,
implicitDescendants: false,
});
}
async function preserveSourcePathStyle(
pattern: string,
matches: string[],
): Promise<string[]> {
if (path.isAbsolute(pattern)) {
return matches;
}
const cwd = await fs.realpath(process.cwd());
return matches.map((match) => path.relative(cwd, match) || ".");
}
function explicitlyTargetsHiddenPath(pattern: string): boolean {
return splitSourcePath(pattern).some(isHiddenPathSegment);
}
function splitSourcePath(source: string): string[] {
const root = path.parse(source).root;
const relativePath = source.slice(root.length);
const separatorPattern = process.platform === "win32" ? /[/\\]+/ : /\/+/;
return relativePath.split(separatorPattern).filter(Boolean);
}
function isHiddenPathSegment(segment: string): boolean {
return segment.startsWith(".") && segment !== "." && segment !== "..";
}
function normalizeGlobPattern(pattern: string): string {
if (process.platform !== "win32") {
return pattern;
}
return pattern.replace(/\\/g, "/");
}