feat: release 2.5.1
This commit is contained in:
+174
-21
@@ -1,6 +1,7 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { execFile, spawn } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { constants } from "node:fs";
|
||||
import { access } from "node:fs/promises";
|
||||
import {
|
||||
chmod,
|
||||
lstat,
|
||||
@@ -10,9 +11,10 @@ import {
|
||||
readlink,
|
||||
realpath,
|
||||
symlink,
|
||||
readFile,
|
||||
} from "node:fs/promises";
|
||||
import type { Stats } from "node:fs";
|
||||
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
||||
import { delimiter, dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import {
|
||||
BUILD_PROTOCOL_VERSION,
|
||||
@@ -24,6 +26,17 @@ import {
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export async function gitAvailable(): Promise<boolean> {
|
||||
for (const directory of (process.env.PATH ?? "").split(delimiter)) {
|
||||
if (!directory) continue;
|
||||
try {
|
||||
await access(resolve(directory, process.platform === "win32" ? "git.exe" : "git"), constants.X_OK);
|
||||
return true;
|
||||
} catch { /* Continue searching PATH. */ }
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export type WorkspaceBlob = {
|
||||
digest: Sha256Digest;
|
||||
data: Uint8Array;
|
||||
@@ -103,31 +116,166 @@ async function selectedFiles(root: string): Promise<string[]> {
|
||||
);
|
||||
}
|
||||
|
||||
async function isIgnored(root: string, path: string): Promise<boolean> {
|
||||
async function filesystemFiles(root: string): Promise<string[]> {
|
||||
const files: string[] = [];
|
||||
const ignoredDirectories = new Set([
|
||||
".git", ".hg", ".svn", "node_modules", "vendor", "bower_components",
|
||||
".venv", "venv", "__pycache__", ".tox", ".mypy_cache", ".pytest_cache",
|
||||
".next", ".nuxt", ".svelte-kit", ".cache", ".turbo", "dist", "build", "coverage",
|
||||
"target", "out", "tmp", "temp",
|
||||
]);
|
||||
const sensitiveDirectory = /(?:^|[-_.])(?:secrets?|credentials?|configs?)(?:$|[-_.])/i;
|
||||
const ignoreRules: Array<{ base: string; pattern: string; directory: boolean }> = [];
|
||||
const loadIgnore = async (directory: string): Promise<void> => {
|
||||
try {
|
||||
const content = await readFile(resolve(directory, ".gitignore"), "utf8");
|
||||
for (const raw of content.split(/\r?\n/)) {
|
||||
const line = raw.trim();
|
||||
if (!line || line.startsWith("#")) continue;
|
||||
// Negations are skipped: safely re-including descendants requires
|
||||
// Git's parent-directory semantics, so fallback stays fail-closed.
|
||||
if (line.startsWith("!")) continue;
|
||||
const rule = line.replace(/^\//, "");
|
||||
if (rule) ignoreRules.push({ base: relative(root, directory).split(sep).join("/"), pattern: rule.replace(/\/$/, ""), directory: line.endsWith("/") });
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||
}
|
||||
};
|
||||
const ignoredByRules = (path: string, isDirectory: boolean): boolean => {
|
||||
let ignored = false;
|
||||
for (const rule of ignoreRules) {
|
||||
const prefix = rule.base ? `${rule.base}/` : "";
|
||||
if (rule.base && path !== rule.base && !path.startsWith(prefix)) continue;
|
||||
const local = rule.base && path.startsWith(prefix) ? path.slice(prefix.length) : path;
|
||||
const glob = rule.pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "__DOUBLESTAR__").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]").replace(/__DOUBLESTAR__/g, ".*");
|
||||
const matcher = new RegExp(`^(?:${glob})(?:/.*)?$`);
|
||||
const basenameMatcher = new RegExp(`^(?:${glob})$`);
|
||||
if ((matcher.test(local) || local.split("/").some((part) => basenameMatcher.test(part))) && (!rule.directory || isDirectory || local.includes("/"))) ignored = true;
|
||||
}
|
||||
return ignored;
|
||||
};
|
||||
const visit = async (directory: string): Promise<void> => {
|
||||
await loadIgnore(directory);
|
||||
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
||||
const source = resolve(directory, entry.name);
|
||||
const path = relative(root, source).split(sep).join("/");
|
||||
if (entry.isDirectory()) {
|
||||
if (ignoredDirectories.has(entry.name) || sensitiveDirectory.test(entry.name) || ignoredByRules(path, true)) continue;
|
||||
await visit(source);
|
||||
}
|
||||
else if (entry.isFile() || entry.isSymbolicLink()) {
|
||||
if (ignoredByRules(path, false)) continue;
|
||||
if (/^\.env/i.test(entry.name) || /(?:secret|credential|password|token|private[-_.]?key)/i.test(entry.name) || /^(?:id_rsa|id_ed25519|known_hosts|config\.json|\.npmrc|\.pypirc|\.netrc)$/i.test(entry.name)) continue;
|
||||
files.push(path);
|
||||
} else {
|
||||
throw new Error(`Special files are not allowed in workspaces: ${relative(root, source).split(sep).join("/")}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
await visit(root);
|
||||
return files.sort((a, b) => Buffer.from(a).compare(Buffer.from(b)));
|
||||
}
|
||||
|
||||
async function isGitRepository(root: string): Promise<boolean> {
|
||||
if (!(await gitAvailable())) return false;
|
||||
try {
|
||||
await execFileAsync("git", ["-C", root, "check-ignore", "-q", "--", path]);
|
||||
await execFileAsync("git", ["-C", root, "rev-parse", "--show-toplevel"]);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if ((error as { code?: number }).code === 1) return false;
|
||||
const code = (error as { code?: unknown }).code;
|
||||
const exitCode = (error as { exitCode?: number }).exitCode;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (code === "ENOENT") return false;
|
||||
if (exitCode === 128 || code === 128 || /not a git repository/i.test(message)) return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function rejectSelectedSpecialFiles(
|
||||
root: string,
|
||||
directory = root,
|
||||
): Promise<void> {
|
||||
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
||||
if (directory === root && entry.name === ".git") continue;
|
||||
const source = resolve(directory, entry.name);
|
||||
const path = relative(root, source).split(sep).join("/");
|
||||
if (entry.isDirectory()) {
|
||||
if (!(await isIgnored(root, path)))
|
||||
await rejectSelectedSpecialFiles(root, source);
|
||||
continue;
|
||||
async function rejectSelectedSpecialFiles(root: string): Promise<void> {
|
||||
// Let Git identify ignored directories using its own ignore engine. With
|
||||
// --directory, ignored trees are returned as directory entries, so the
|
||||
// filesystem walk below can prune them without visiting their contents.
|
||||
const ignoredResult = await execFileAsync(
|
||||
"git",
|
||||
[
|
||||
"-C",
|
||||
root,
|
||||
"ls-files",
|
||||
"--others",
|
||||
"--ignored",
|
||||
"--exclude-standard",
|
||||
"--directory",
|
||||
"-z",
|
||||
],
|
||||
{ encoding: "buffer", maxBuffer: 64 * 1024 * 1024 },
|
||||
);
|
||||
const decoder = new TextDecoder("utf-8", { fatal: true });
|
||||
const ignoredDirectories = new Set<string>();
|
||||
let start = 0;
|
||||
for (
|
||||
let end = ignoredResult.stdout.indexOf(0);
|
||||
end !== -1;
|
||||
end = ignoredResult.stdout.indexOf(0, start)
|
||||
) {
|
||||
if (end > start) {
|
||||
const path = decoder.decode(ignoredResult.stdout.subarray(start, end));
|
||||
if (path.endsWith("/")) ignoredDirectories.add(path.slice(0, -1));
|
||||
}
|
||||
if (entry.isFile() || entry.isSymbolicLink()) continue;
|
||||
if (!(await isIgnored(root, path)) || entry.name.startsWith(".env"))
|
||||
start = end + 1;
|
||||
}
|
||||
|
||||
const specialPaths: string[] = [];
|
||||
const visit = async (directory: string): Promise<void> => {
|
||||
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
||||
if (directory === root && entry.name === ".git") continue;
|
||||
const source = resolve(directory, entry.name);
|
||||
const path = relative(root, source).split(sep).join("/");
|
||||
if (entry.isDirectory()) {
|
||||
if (!ignoredDirectories.has(path)) await visit(source);
|
||||
} else if (!entry.isFile() && !entry.isSymbolicLink()) {
|
||||
specialPaths.push(path);
|
||||
}
|
||||
}
|
||||
};
|
||||
await visit(root);
|
||||
|
||||
if (specialPaths.length === 0) return;
|
||||
const input = Buffer.from(`${specialPaths.join("\0")}\0`);
|
||||
const child = spawn("git", ["-C", root, "check-ignore", "--stdin", "-z"]);
|
||||
const output: Buffer[] = [];
|
||||
const errors: Buffer[] = [];
|
||||
child.stdout.on("data", (chunk: Buffer) => output.push(chunk));
|
||||
child.stderr.on("data", (chunk: Buffer) => errors.push(chunk));
|
||||
const completed = new Promise<void>((resolveExit, rejectExit) => {
|
||||
child.once("error", rejectExit);
|
||||
child.once("close", (code) => {
|
||||
if (code === 0 || code === 1) resolveExit();
|
||||
else
|
||||
rejectExit(
|
||||
new Error(
|
||||
Buffer.concat(errors).toString("utf8") ||
|
||||
`git check-ignore exited with ${code}`,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
child.stdin.end(input);
|
||||
await completed;
|
||||
const stdout = Buffer.concat(output);
|
||||
const ignored = new Set<string>();
|
||||
start = 0;
|
||||
for (
|
||||
let end = stdout.indexOf(0);
|
||||
end !== -1;
|
||||
end = stdout.indexOf(0, start)
|
||||
) {
|
||||
if (end > start) ignored.add(decoder.decode(stdout.subarray(start, end)));
|
||||
start = end + 1;
|
||||
}
|
||||
for (const path of specialPaths) {
|
||||
const name = path.slice(path.lastIndexOf("/") + 1);
|
||||
if (!ignored.has(path) || name.startsWith(".env"))
|
||||
throw new Error(`Special files are not allowed in workspaces: ${path}`);
|
||||
}
|
||||
}
|
||||
@@ -201,8 +349,13 @@ export async function enumerateWorkspace(
|
||||
root: string,
|
||||
): Promise<WorkspaceSnapshot> {
|
||||
const repository = await realpath(root);
|
||||
await rejectSelectedSpecialFiles(repository);
|
||||
const paths = await selectedFiles(repository);
|
||||
const gitBacked = await isGitRepository(repository);
|
||||
const paths = gitBacked
|
||||
? await (async () => {
|
||||
await rejectSelectedSpecialFiles(repository);
|
||||
return selectedFiles(repository);
|
||||
})()
|
||||
: await filesystemFiles(repository);
|
||||
const files: WorkspaceFile[] = [];
|
||||
const blobs = new Map<Sha256Digest, Uint8Array>();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user