the universal sandbox runtime for agents and humans.
pocketenv.io
sandbox
openclaw
agent
claude-code
vercel-sandbox
deno-sandbox
cloudflare-sandbox
atproto
sprites
daytona
1import ignore from "ignore";
2import { readFile, readdir } from "node:fs/promises";
3import { join, dirname, basename } from "node:path";
4
5const IGNORE_FILE_NAMES = [
6 ".pocketenvignore",
7 ".gitignore",
8 ".npmignore",
9 ".dockerignore",
10];
11
12export type IgnoreContext = {
13 /** Path of the ignore file's directory relative to the scan root. Empty string for root. */
14 dir: string;
15 ig: ReturnType<typeof ignore>;
16};
17
18/**
19 * Recursively finds all ignore files under `root` and loads them into
20 * per-directory contexts. Each context's patterns are scoped to its directory,
21 * matching how git resolves nested .gitignore files.
22 */
23export async function loadIgnoreFiles(root: string): Promise<IgnoreContext[]> {
24 const contexts: IgnoreContext[] = [];
25
26 // readdir with recursive:true finds hidden ignore files (e.g. apps/api/.gitignore)
27 // which Node.js glob("**/.gitignore") silently skips.
28 const ignoreFileSet = new Set(IGNORE_FILE_NAMES);
29 const candidates = (await readdir(root, { recursive: true })).filter(
30 (entry) => ignoreFileSet.has(basename(entry)),
31 );
32
33 for (const file of candidates) {
34 try {
35 const ig = ignore();
36 ig.add(await readFile(join(root, file), "utf8"));
37 const dir = dirname(file);
38 contexts.push({ dir: dir === "." ? "" : dir, ig });
39 } catch {
40 // skip unreadable files
41 }
42 }
43
44 return contexts;
45}
46
47/**
48 * Returns an `isIgnored(path)` function that checks a relative path against
49 * all loaded ignore contexts.
50 *
51 * For each context whose directory is an ancestor of the path, the path is
52 * made relative to that context's directory and then tested with the suffix
53 * approach (see below).
54 *
55 * Why the suffix approach?
56 * `ig.ignores('node_modules')` returns false for pattern `node_modules/`
57 * (trailing slash = directory-only) because the ignore package requires the
58 * tested path to end with `/` to match. Checking each path suffix both with
59 * and without a trailing slash fixes this and also makes unanchored patterns
60 * (e.g. `node_modules`) apply at any depth within the context's directory.
61 */
62export function makeIsIgnored(contexts: IgnoreContext[]) {
63 return function isIgnored(path: string): boolean {
64 return contexts.some(({ dir, ig }) => {
65 const rel =
66 dir === ""
67 ? path
68 : path.startsWith(dir + "/")
69 ? path.slice(dir.length + 1)
70 : null;
71
72 if (rel === null) return false;
73
74 const parts = rel.split("/");
75 return parts.some((_, i) => {
76 const sub = parts.slice(i).join("/");
77 return ig.ignores(sub) || ig.ignores(sub + "/");
78 });
79 });
80 };
81}