shilo-cli 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +338 -255
- package/bin/shilo.js +211 -165
- package/package.json +1 -1
- package/src/ShiloClient.js +1066 -837
- package/src/args.js +32 -8
- package/src/checks.js +57 -0
- package/src/commands/deploys.js +105 -0
- package/src/commands/diff.js +19 -3
- package/src/commands/export.js +226 -0
- package/src/commands/init.js +8 -0
- package/src/commands/log.js +4 -0
- package/src/commands/pull.js +86 -2
- package/src/commands/push.js +668 -69
- package/src/commands/review.js +101 -0
- package/src/commands/status.js +148 -14
- package/src/exportgit.js +417 -0
- package/src/gitobjects.js +532 -0
- package/src/projectconfig.js +76 -0
- package/src/safelist.js +101 -0
- package/src/secrets.js +75 -9
- package/src/sync.js +56 -5
- package/src/walk.js +30 -0
package/src/safelist.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Decisiones "esto no es un secreto" que la persona ya tomó en esta carpeta.
|
|
5
|
+
*
|
|
6
|
+
* Vive en `.shilo/secretos-seguros.json` (`.shilo/` está en los ignores por
|
|
7
|
+
* defecto: nunca se sube). Guarda la huella de la LÍNEA (ver
|
|
8
|
+
* `secrets.fingerprintOf`), nunca el valor: si la línea cambia, la huella deja
|
|
9
|
+
* de coincidir y se vuelve a preguntar. Así no hace falta `--allow-secrets` en
|
|
10
|
+
* cada push, y esa bandera —que apaga todos los avisos, también los reales—
|
|
11
|
+
* queda para casos excepcionales.
|
|
12
|
+
*
|
|
13
|
+
* {
|
|
14
|
+
* "version": 1,
|
|
15
|
+
* "seguros": [{ "path": "src/supabase.js", "fingerprint": "<sha256>", "label": "...", "at": "<ISO>" }]
|
|
16
|
+
* }
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const fs = require('fs');
|
|
20
|
+
const path = require('path');
|
|
21
|
+
|
|
22
|
+
const FILE = path.join('.shilo', 'secretos-seguros.json');
|
|
23
|
+
|
|
24
|
+
function load(dir) {
|
|
25
|
+
try {
|
|
26
|
+
const raw = JSON.parse(fs.readFileSync(path.join(dir, FILE), 'utf8'));
|
|
27
|
+
return Array.isArray(raw.seguros) ? raw.seguros : [];
|
|
28
|
+
} catch (_) {
|
|
29
|
+
return [];
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const keyOf = (p, fp) => `${p}\n${fp}`;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Separa los hallazgos ya marcados como seguros de los que siguen pendientes.
|
|
37
|
+
* Sólo los de contenido se pueden marcar: un `.env` no se "aprueba", se ignora.
|
|
38
|
+
*/
|
|
39
|
+
function partition(dir, findings) {
|
|
40
|
+
const known = new Set(load(dir).map((e) => keyOf(e.path, e.fingerprint)));
|
|
41
|
+
const pending = [];
|
|
42
|
+
const accepted = [];
|
|
43
|
+
for (const f of findings) {
|
|
44
|
+
if (f.kind === 'content' && f.fingerprint && known.has(keyOf(f.relPath, f.fingerprint))) {
|
|
45
|
+
accepted.push(f);
|
|
46
|
+
} else {
|
|
47
|
+
pending.push(f);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return { pending, accepted };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Anota hallazgos de contenido como seguros. Devuelve false si no pudo escribir. */
|
|
54
|
+
function remember(dir, findings) {
|
|
55
|
+
const list = load(dir);
|
|
56
|
+
const known = new Set(list.map((e) => keyOf(e.path, e.fingerprint)));
|
|
57
|
+
const at = new Date().toISOString();
|
|
58
|
+
for (const f of findings) {
|
|
59
|
+
if (f.kind !== 'content' || !f.fingerprint) continue;
|
|
60
|
+
const k = keyOf(f.relPath, f.fingerprint);
|
|
61
|
+
if (known.has(k)) continue;
|
|
62
|
+
known.add(k);
|
|
63
|
+
list.push({ path: f.relPath, fingerprint: f.fingerprint, label: f.label, at });
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
fs.mkdirSync(path.join(dir, '.shilo'), { recursive: true });
|
|
67
|
+
fs.writeFileSync(
|
|
68
|
+
path.join(dir, FILE),
|
|
69
|
+
JSON.stringify({ version: 1, seguros: list }, null, 2) + '\n',
|
|
70
|
+
'utf8'
|
|
71
|
+
);
|
|
72
|
+
return true;
|
|
73
|
+
} catch (_) {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Agrega rutas a `.shiloignore` (lo crea si no existe). Devuelve false si no
|
|
80
|
+
* pudo escribir.
|
|
81
|
+
*/
|
|
82
|
+
function addToShiloignore(dir, relPaths) {
|
|
83
|
+
const p = path.join(dir, '.shiloignore');
|
|
84
|
+
try {
|
|
85
|
+
const current = fs.existsSync(p) ? fs.readFileSync(p, 'utf8') : '';
|
|
86
|
+
const lines = new Set(current.split(/\r?\n/).map((l) => l.trim()));
|
|
87
|
+
const add = relPaths.map((r) => `/${r}`).filter((r) => !lines.has(r));
|
|
88
|
+
if (add.length === 0) return true;
|
|
89
|
+
const sep = current && !current.endsWith('\n') ? '\n' : '';
|
|
90
|
+
fs.writeFileSync(
|
|
91
|
+
p,
|
|
92
|
+
`${current}${sep}# Agregado por shilo push: posibles contraseñas\n${add.join('\n')}\n`,
|
|
93
|
+
'utf8'
|
|
94
|
+
);
|
|
95
|
+
return true;
|
|
96
|
+
} catch (_) {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
module.exports = { FILE, load, partition, remember, addToShiloignore };
|
package/src/secrets.js
CHANGED
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
* NUNCA se imprime el valor detectado, sólo archivo, línea y qué parece ser.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
+
const crypto = require('crypto');
|
|
21
|
+
|
|
20
22
|
// Patrones de nombre (mismo subconjunto glob que .gitignore, sobre el basename).
|
|
21
23
|
const DENY_NAMES = [
|
|
22
24
|
'.env',
|
|
@@ -75,6 +77,57 @@ function deniedByName(relPath) {
|
|
|
75
77
|
return null;
|
|
76
78
|
}
|
|
77
79
|
|
|
80
|
+
const JWT_RE = /\bey[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/;
|
|
81
|
+
const JWT_RE_ALL = new RegExp(JWT_RE.source, 'g');
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Qué es un JWT según su payload, sin verificar la firma (no hace falta: sólo
|
|
85
|
+
* se decide si avisar). Supabase pone el rol ahí:
|
|
86
|
+
*
|
|
87
|
+
* - `anon` la clave pública: está hecha para ir en el navegador.
|
|
88
|
+
* - `service_role` la que saltea todas las reglas de acceso: secreta.
|
|
89
|
+
*
|
|
90
|
+
* Devuelve 'public', 'secret' o null (no se pudo leer, u otro JWT).
|
|
91
|
+
*/
|
|
92
|
+
function classifyJwt(token) {
|
|
93
|
+
const part = token.split('.')[1];
|
|
94
|
+
if (!part) return null;
|
|
95
|
+
let payload;
|
|
96
|
+
try {
|
|
97
|
+
payload = JSON.parse(Buffer.from(part, 'base64url').toString('utf8'));
|
|
98
|
+
} catch (_) {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
if (!payload || typeof payload !== 'object') return null;
|
|
102
|
+
if (payload.role === 'anon') return 'public';
|
|
103
|
+
if (payload.role === 'service_role') return 'secret';
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* La etiqueta del primer JWT de la línea que valga la pena avisar, o null si
|
|
109
|
+
* todos son claves públicas conocidas.
|
|
110
|
+
*/
|
|
111
|
+
function jwtLabel(line) {
|
|
112
|
+
let label = null;
|
|
113
|
+
for (const m of line.matchAll(JWT_RE_ALL)) {
|
|
114
|
+
const kind = classifyJwt(m[0]);
|
|
115
|
+
if (kind === 'public') continue;
|
|
116
|
+
if (kind === 'secret') return 'clave service_role de Supabase (secreta)';
|
|
117
|
+
label = 'token de Supabase / JWT';
|
|
118
|
+
}
|
|
119
|
+
return label;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Huella de una línea con un posible secreto: permite recordar "esto ya me
|
|
124
|
+
* dijeron que es seguro" sin guardar el valor. Si la línea cambia, la huella
|
|
125
|
+
* cambia y se vuelve a preguntar.
|
|
126
|
+
*/
|
|
127
|
+
function fingerprintOf(line) {
|
|
128
|
+
return crypto.createHash('sha256').update(line.trim(), 'utf8').digest('hex');
|
|
129
|
+
}
|
|
130
|
+
|
|
78
131
|
/**
|
|
79
132
|
* Formas de credencial reconocibles por su prefijo/estructura. Se apunta a
|
|
80
133
|
* precisión razonable: prefijos propietarios y claves PEM, no "cualquier string
|
|
@@ -88,7 +141,8 @@ const CONTENT_PATTERNS = [
|
|
|
88
141
|
{ label: 'clave de API de Google', re: /\bAIza[0-9A-Za-z_-]{35}\b/ },
|
|
89
142
|
{ label: 'token de Slack', re: /\bxox[abprs]-[0-9A-Za-z-]{10,}/ },
|
|
90
143
|
{ label: 'clave de Stripe', re: /\b[sr]k_live_[0-9A-Za-z]{20,}/ },
|
|
91
|
-
{ label: '
|
|
144
|
+
{ label: 'clave secreta de Supabase', re: /\bsb_secret_[A-Za-z0-9_-]{20,}/ },
|
|
145
|
+
{ label: 'token de Supabase / JWT', re: JWT_RE, jwt: true },
|
|
92
146
|
{ label: 'clave privada PEM', re: /-----BEGIN (?:[A-Z ]+ )?PRIVATE KEY-----/ },
|
|
93
147
|
{ label: 'token de acceso personal de Shilo', re: /\bshilo_pat_[A-Za-z0-9_-]{20,}/ },
|
|
94
148
|
{ label: 'contraseña en una URL', re: /\b[a-z][a-z0-9+.-]*:\/\/[^\s/:@]+:[^\s/@]{6,}@/i },
|
|
@@ -99,7 +153,8 @@ const CONTENT_PATTERNS = [
|
|
|
99
153
|
const SCAN_BYTES = 8192;
|
|
100
154
|
|
|
101
155
|
/**
|
|
102
|
-
* Escanea un fragmento de texto. Devuelve `[{ line, label }]` —
|
|
156
|
+
* Escanea un fragmento de texto. Devuelve `[{ line, label, fingerprint }]` —
|
|
157
|
+
* sin el valor.
|
|
103
158
|
*/
|
|
104
159
|
function scanText(text) {
|
|
105
160
|
const hits = [];
|
|
@@ -107,11 +162,14 @@ function scanText(text) {
|
|
|
107
162
|
for (let i = 0; i < lines.length; i++) {
|
|
108
163
|
const line = lines[i];
|
|
109
164
|
if (line.length > 4000) continue; // minificado/base64: ruido, no señal
|
|
110
|
-
for (const { label, re } of CONTENT_PATTERNS) {
|
|
111
|
-
if (re.test(line))
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
165
|
+
for (const { label, re, jwt } of CONTENT_PATTERNS) {
|
|
166
|
+
if (!re.test(line)) continue;
|
|
167
|
+
// La clave `anon` de Supabase es pública por diseño. Avisar por ella
|
|
168
|
+
// empujaba a usar --allow-secrets, que apaga también los avisos reales.
|
|
169
|
+
const finalLabel = jwt ? jwtLabel(line) : label;
|
|
170
|
+
if (!finalLabel) continue;
|
|
171
|
+
hits.push({ line: i + 1, label: finalLabel, fingerprint: fingerprintOf(line) });
|
|
172
|
+
break; // un hallazgo por línea alcanza para avisar
|
|
115
173
|
}
|
|
116
174
|
}
|
|
117
175
|
return hits;
|
|
@@ -132,7 +190,13 @@ function inspect(relPath, head) {
|
|
|
132
190
|
|
|
133
191
|
if (typeof head === 'string' && head) {
|
|
134
192
|
for (const hit of scanText(head.slice(0, SCAN_BYTES))) {
|
|
135
|
-
findings.push({
|
|
193
|
+
findings.push({
|
|
194
|
+
relPath,
|
|
195
|
+
kind: 'content',
|
|
196
|
+
line: hit.line,
|
|
197
|
+
label: hit.label,
|
|
198
|
+
fingerprint: hit.fingerprint,
|
|
199
|
+
});
|
|
136
200
|
}
|
|
137
201
|
}
|
|
138
202
|
|
|
@@ -151,7 +215,7 @@ function groupByFile(findings) {
|
|
|
151
215
|
}
|
|
152
216
|
const entry = byPath.get(f.relPath);
|
|
153
217
|
if (f.kind === 'name') entry.name = f.pattern;
|
|
154
|
-
else entry.content.push({ line: f.line, label: f.label });
|
|
218
|
+
else entry.content.push({ line: f.line, label: f.label, fingerprint: f.fingerprint });
|
|
155
219
|
}
|
|
156
220
|
return [...byPath.values()];
|
|
157
221
|
}
|
|
@@ -162,6 +226,8 @@ module.exports = {
|
|
|
162
226
|
CONTENT_PATTERNS,
|
|
163
227
|
SCAN_BYTES,
|
|
164
228
|
deniedByName,
|
|
229
|
+
classifyJwt,
|
|
230
|
+
fingerprintOf,
|
|
165
231
|
scanText,
|
|
166
232
|
inspect,
|
|
167
233
|
groupByFile,
|
package/src/sync.js
CHANGED
|
@@ -6,14 +6,15 @@
|
|
|
6
6
|
* cambios a partir del historial de commits.
|
|
7
7
|
*
|
|
8
8
|
* Todo lo de acá es de sólo lectura: ninguna función escribe en disco ni en el
|
|
9
|
-
* servidor. La idea es que un `shilo status` pueda contestar "¿estoy
|
|
9
|
+
* servidor (`refineByHash` lee archivos locales, nada más). La idea es que un `shilo status` pueda contestar "¿estoy
|
|
10
10
|
* desactualizado?" sin bajar contenido.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
const fs = require('fs');
|
|
14
14
|
const path = require('path');
|
|
15
15
|
const config = require('./config');
|
|
16
|
-
const { walk } = require('./walk');
|
|
16
|
+
const { walk, makePathMatcher } = require('./walk');
|
|
17
|
+
const pushindex = require('./pushindex');
|
|
17
18
|
|
|
18
19
|
const CONCURRENCY = 8; // pool para el N+1 de listar carpetas (el backend no tiene árbol recursivo)
|
|
19
20
|
const MAX_CONCURRENCY = 32;
|
|
@@ -231,7 +232,7 @@ function walkLocal(dir) {
|
|
|
231
232
|
const { files, skipped, ignored } = walk(dir);
|
|
232
233
|
return {
|
|
233
234
|
files: files
|
|
234
|
-
.map((f) => ({ repoPath: f.relPath, absPath: f.absPath, size: f.size }))
|
|
235
|
+
.map((f) => ({ repoPath: f.relPath, absPath: f.absPath, size: f.size, mtimeMs: f.mtimeMs }))
|
|
235
236
|
.sort((a, b) => a.repoPath.localeCompare(b.repoPath)),
|
|
236
237
|
skipped,
|
|
237
238
|
ignored,
|
|
@@ -241,8 +242,8 @@ function walkLocal(dir) {
|
|
|
241
242
|
/**
|
|
242
243
|
* Compara los dos árboles por ruta y tamaño. NO baja contenido: por eso un
|
|
243
244
|
* archivo con el mismo tamaño en ambos lados cae en `same` aunque su contenido
|
|
244
|
-
* pudiera diferir (mismo largo, distinto texto).
|
|
245
|
-
*
|
|
245
|
+
* pudiera diferir (mismo largo, distinto texto). `refineByHash` lo confirma
|
|
246
|
+
* por contenido sin bajar nada; `diff` y `push` comparan byte a byte.
|
|
246
247
|
*
|
|
247
248
|
* Devuelve:
|
|
248
249
|
* {
|
|
@@ -285,6 +286,52 @@ function compareTrees(localFiles, remoteFiles) {
|
|
|
285
286
|
return { onlyLocal, onlyRemote, differing, same };
|
|
286
287
|
}
|
|
287
288
|
|
|
289
|
+
/**
|
|
290
|
+
* Confirma por contenido los `same` de `compareTrees`: cambiar un número por
|
|
291
|
+
* otro deja el archivo del mismo tamaño, y sólo por tamaño pasaba como "sin
|
|
292
|
+
* cambios". El remoto publica el SHA-256 en el listado (`content_hash`), así que
|
|
293
|
+
* esto sigue sin bajar nada: sólo lee archivos locales, y ni eso si el índice
|
|
294
|
+
* del push (`.shilo/index.json`) dice que el archivo está intacto y su hash ya
|
|
295
|
+
* coincidía.
|
|
296
|
+
*
|
|
297
|
+
* Muta y devuelve `cmp`: lo que difiere pasa de `same` a `differing`. Un
|
|
298
|
+
* remoto sin hash (anterior a la migración 010) o un archivo ilegible quedan
|
|
299
|
+
* como estaban.
|
|
300
|
+
*/
|
|
301
|
+
function refineByHash(cmp, { dir, projectId, baseURL } = {}) {
|
|
302
|
+
const index = dir ? pushindex.read(dir, { projectId, baseURL }) : { entries: {} };
|
|
303
|
+
|
|
304
|
+
const same = [];
|
|
305
|
+
for (const item of cmp.same) {
|
|
306
|
+
const hash = item.remote && item.remote.content_hash;
|
|
307
|
+
if (!hash) {
|
|
308
|
+
same.push(item);
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
const { fresh, entry } = pushindex.isFresh(index, {
|
|
312
|
+
relPath: item.repoPath,
|
|
313
|
+
size: item.local.size,
|
|
314
|
+
mtimeMs: item.local.mtimeMs,
|
|
315
|
+
});
|
|
316
|
+
if (fresh && entry && entry.hash === hash) {
|
|
317
|
+
same.push(item);
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
let localHash;
|
|
321
|
+
try {
|
|
322
|
+
localHash = pushindex.hashContent(fs.readFileSync(item.local.absPath));
|
|
323
|
+
} catch (_) {
|
|
324
|
+
same.push(item);
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
if (localHash === hash) same.push(item);
|
|
328
|
+
else cmp.differing.push(item);
|
|
329
|
+
}
|
|
330
|
+
cmp.same = same;
|
|
331
|
+
cmp.differing.sort((a, b) => a.repoPath.localeCompare(b.repoPath));
|
|
332
|
+
return cmp;
|
|
333
|
+
}
|
|
334
|
+
|
|
288
335
|
/**
|
|
289
336
|
* Baja las últimas páginas del historial con sus archivos y arma un índice
|
|
290
337
|
* `file_id -> { message, author, date, hash, action }` con el commit MÁS
|
|
@@ -409,7 +456,11 @@ module.exports = {
|
|
|
409
456
|
runPool,
|
|
410
457
|
walkRemote,
|
|
411
458
|
walkLocal,
|
|
459
|
+
// Re-exportado para que los comandos filtren el árbol REMOTO con los mismos
|
|
460
|
+
// ignores que `walkLocal` aplica al local, sin importar `walk.js` aparte.
|
|
461
|
+
makePathMatcher,
|
|
412
462
|
compareTrees,
|
|
463
|
+
refineByHash,
|
|
413
464
|
buildAuthorIndex,
|
|
414
465
|
describeAuthor,
|
|
415
466
|
formatDate,
|
package/src/walk.js
CHANGED
|
@@ -195,6 +195,35 @@ function loadIgnoreRules(rootDir, extraLines = []) {
|
|
|
195
195
|
return rules;
|
|
196
196
|
}
|
|
197
197
|
|
|
198
|
+
/**
|
|
199
|
+
* Matcher de ignores por RUTA DE REPO, sin tocar disco.
|
|
200
|
+
*
|
|
201
|
+
* `walk()` decide los ignores mientras recorre el árbol local, así que sólo
|
|
202
|
+
* contesta por lo que existe acá. Esto contesta la otra mitad: dada una ruta
|
|
203
|
+
* que vino del REMOTO, ¿estaría ignorada localmente? Sin esto los ignores valen
|
|
204
|
+
* en un solo sentido —el push— y el árbol remoto nunca se filtra: `status`
|
|
205
|
+
* cuenta como "atrás" archivos que jamás va a tener y `pull` baja cosas
|
|
206
|
+
* ignoradas.
|
|
207
|
+
*
|
|
208
|
+
* Sólo carga las reglas de la raíz: un ignore anidado vive dentro de una
|
|
209
|
+
* carpeta que, si el archivo sólo existe en el remoto, puede no estar en disco.
|
|
210
|
+
* Es una aproximación conservadora — ignora de menos, nunca de más.
|
|
211
|
+
*/
|
|
212
|
+
function makePathMatcher(rootDir, { extraIgnore = [] } = {}) {
|
|
213
|
+
const isIgnored = makeMatcher(loadIgnoreRules(rootDir, extraIgnore));
|
|
214
|
+
return function isIgnoredPath(repoPath) {
|
|
215
|
+
const parts = String(repoPath).split('/');
|
|
216
|
+
// Cada ancestro se consulta como CARPETA: `makeMatcher` saltea las reglas
|
|
217
|
+
// `dirOnly` cuando `isDir` es false, así que preguntar derecho por
|
|
218
|
+
// `.claude/settings.local.json` daría false y la regla `.claude/` no
|
|
219
|
+
// alcanzaría nunca a lo de adentro.
|
|
220
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
221
|
+
if (isIgnored(parts.slice(0, i + 1).join('/'), true)) return true;
|
|
222
|
+
}
|
|
223
|
+
return isIgnored(repoPath, false);
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
198
227
|
/**
|
|
199
228
|
* Recorre `rootDir` y devuelve:
|
|
200
229
|
* {
|
|
@@ -312,6 +341,7 @@ module.exports = {
|
|
|
312
341
|
loadIgnoreRules,
|
|
313
342
|
loadDirRules,
|
|
314
343
|
makeMatcher,
|
|
344
|
+
makePathMatcher,
|
|
315
345
|
compileRule,
|
|
316
346
|
globToRegExp,
|
|
317
347
|
looksLikeGitDir,
|