shilo-cli 0.3.2 → 0.5.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/src/ui.js CHANGED
@@ -33,4 +33,64 @@ function error(msg) {
33
33
  console.error(`${c.red('✗')} ${msg}`);
34
34
  }
35
35
 
36
- module.exports = { c, info, success, warn, error };
36
+ /** ¿La salida es una terminal interactiva? (redirigida a archivo/CI: no) */
37
+ const isTTY = !!process.stdout.isTTY;
38
+
39
+ function formatEta(secs) {
40
+ if (!Number.isFinite(secs) || secs < 0) return '';
41
+ if (secs < 60) return `${Math.max(1, Math.round(secs))}s`;
42
+ const mins = Math.floor(secs / 60);
43
+ const rest = Math.round(secs % 60);
44
+ return `${mins}m ${rest}s`;
45
+ }
46
+
47
+ /**
48
+ * Contador de progreso de una sola línea.
49
+ *
50
+ * En una terminal reescribe la misma línea con porcentaje y ETA; sin TTY no
51
+ * dibuja nada (una barra con `\r` en un log de CI es ruido ilegible) y en su
52
+ * lugar `line()` emite una línea por ítem. Así el mismo código sirve para el
53
+ * uso interactivo y para el registro.
54
+ *
55
+ * El repintado se limita a ~10 fps: con cientos de archivos, escribir en cada
56
+ * uno cuesta más que el trabajo que reporta.
57
+ */
58
+ function progress(total, label = '') {
59
+ const start = Date.now();
60
+ let lastLen = 0;
61
+ let lastDraw = 0;
62
+
63
+ return {
64
+ tty: isTTY,
65
+ update(done) {
66
+ if (!isTTY || total <= 0) return;
67
+ const now = Date.now();
68
+ if (done < total && now - lastDraw < 100) return;
69
+ lastDraw = now;
70
+
71
+ const pct = Math.round((done / total) * 100);
72
+ let eta = '';
73
+ if (done > 0 && done < total) {
74
+ const elapsed = (now - start) / 1000;
75
+ const left = formatEta((elapsed / done) * (total - done));
76
+ if (left) eta = `, faltan ~${left}`;
77
+ }
78
+ const line = ` ${label} ${done}/${total} (${pct}%${eta})`;
79
+ const pad = Math.max(0, lastLen - line.length);
80
+ process.stdout.write(`\r${c.dim(line)}${' '.repeat(pad)}`);
81
+ lastLen = line.length;
82
+ },
83
+ /** Borra la línea de progreso para que el mensaje siguiente arranque limpio. */
84
+ clear() {
85
+ if (!isTTY || lastLen === 0) return;
86
+ process.stdout.write(`\r${' '.repeat(lastLen)}\r`);
87
+ lastLen = 0;
88
+ },
89
+ /** Línea por ítem: sólo cuando no hay barra que la pisaría. */
90
+ line(msg) {
91
+ if (!isTTY) console.log(msg);
92
+ },
93
+ };
94
+ }
95
+
96
+ module.exports = { c, info, success, warn, error, isTTY, progress, formatEta };
package/src/walk.js CHANGED
@@ -4,7 +4,13 @@
4
4
  * Recorrido de un directorio local para el push.
5
5
  *
6
6
  * Responsabilidades (todo offline, sin red):
7
- * - aplicar ignores por defecto (.git, node_modules, etc.) + `.shiloignore`;
7
+ * - aplicar ignores por defecto (.git, node_modules, etc.) + `.gitignore` +
8
+ * `.shiloignore`, incluyendo los anidados de cada subcarpeta;
9
+ * - podar repositorios git detectados por su FORMA (HEAD + objects/ + refs/),
10
+ * no por el nombre `.git`: un `.git-ORIGINAL-BACKUP/` trae todo el historial
11
+ * y renombrarlo no lo vuelve inocuo;
12
+ * - descartar nombres reservados de Windows (`nul`, `con`, `com1`…), que en
13
+ * esa plataforma son dispositivos y no archivos;
8
14
  * - descartar nombres inválidos para Shilo (poda subárboles con carpeta inválida);
9
15
  * - respetar la profundidad máxima de carpetas;
10
16
  * - NO leer el contenido completo (eso lo hace el push al clasificar binarios),
@@ -16,7 +22,7 @@
16
22
 
17
23
  const fs = require('fs');
18
24
  const path = require('path');
19
- const { validateName, MAX_DEPTH } = require('./textguard');
25
+ const { validateName, MAX_DEPTH, WIN_RESERVED } = require('./textguard');
20
26
 
21
27
  // Ignores por defecto (nombres de carpeta/archivo, a cualquier profundidad).
22
28
  const DEFAULT_IGNORES = [
@@ -30,11 +36,40 @@ const DEFAULT_IGNORES = [
30
36
  '.vercel/',
31
37
  ];
32
38
 
39
+ // Archivos de ignores que se leen en cada directorio, en orden de precedencia:
40
+ // `.shiloignore` va último para poder re-incluir con `!` algo que git ignora.
41
+ const IGNORE_FILES = ['.gitignore', '.shiloignore'];
42
+
43
+ // `WIN_RESERVED` (CON, NUL, COM1…) vive en textguard porque el backend también
44
+ // los rechaza. Acá se usan además para PODAR: abrir `nul` no devuelve un archivo
45
+ // sino el dispositivo nulo (`readFileSync` da vacío, `statSync` es inconsistente),
46
+ // así que se saltean en toda plataforma y no sólo en Windows.
47
+
48
+ /**
49
+ * ¿Este directorio es un repositorio git (bare o no), sin importar cómo se llame?
50
+ * Un `.git-ORIGINAL-BACKUP/`, `.git.bak/` o un submódulo tienen la misma forma.
51
+ */
52
+ function looksLikeGitDir(absDir) {
53
+ try {
54
+ return (
55
+ fs.existsSync(path.join(absDir, 'HEAD')) &&
56
+ fs.existsSync(path.join(absDir, 'objects')) &&
57
+ fs.existsSync(path.join(absDir, 'refs'))
58
+ );
59
+ } catch (_) {
60
+ return false;
61
+ }
62
+ }
63
+
33
64
  /**
34
65
  * Compila una línea de patrón estilo .gitignore (subconjunto práctico) a una
35
- * regla { negate, dirOnly, anchored, re }.
66
+ * regla { negate, dirOnly, anchored, base, re }.
67
+ *
68
+ * `base` es el prefijo de ruta (POSIX, con `/` final) del directorio donde vive
69
+ * el archivo de ignores: una regla de `public/.gitignore` sólo aplica bajo
70
+ * `public/`.
36
71
  */
37
- function compileRule(raw) {
72
+ function compileRule(raw, base = '') {
38
73
  let line = raw.replace(/\r$/, '');
39
74
  // Ignorar comentarios y líneas vacías.
40
75
  if (!line.trim() || line.trim().startsWith('#')) return null;
@@ -63,8 +98,10 @@ function compileRule(raw) {
63
98
  anchored = true;
64
99
  }
65
100
 
66
- const re = globToRegExp(line);
67
- return { negate, dirOnly, anchored, re };
101
+ // Los anclados se comparan contra la ruta completa, así que llevan el base
102
+ // incorporado; los de basename se filtran por `base` en el matcher.
103
+ const re = globToRegExp(anchored ? base + line : line);
104
+ return { negate, dirOnly, anchored, base, re };
68
105
  }
69
106
 
70
107
  function globToRegExp(glob) {
@@ -97,12 +134,14 @@ function globToRegExp(glob) {
97
134
  */
98
135
  function makeMatcher(rules) {
99
136
  return function isIgnored(relPath, isDir) {
100
- const base = relPath.split('/').pop();
137
+ const name = relPath.split('/').pop();
101
138
  let ignored = false;
102
139
  for (const rule of rules) {
103
140
  if (!rule) continue;
104
141
  if (rule.dirOnly && !isDir) continue;
105
- const target = rule.anchored ? relPath : base;
142
+ // Una regla de un ignore anidado sólo alcanza a su propio subárbol.
143
+ if (rule.base && !relPath.startsWith(rule.base)) continue;
144
+ const target = rule.anchored ? relPath : name;
106
145
  if (rule.re.test(target)) {
107
146
  ignored = !rule.negate;
108
147
  }
@@ -112,47 +151,86 @@ function makeMatcher(rules) {
112
151
  }
113
152
 
114
153
  /**
115
- * Lee y compila reglas de un `.shiloignore` en la raíz (si existe), combinadas
116
- * con los ignores por defecto.
154
+ * Compila las reglas de los archivos de ignores presentes en `absDir`.
155
+ * `relBase` es la ruta POSIX del directorio relativa a la raíz ('' para la raíz).
117
156
  */
118
- function loadIgnoreRules(rootDir, extraLines = []) {
119
- const lines = [...DEFAULT_IGNORES, ...extraLines];
120
- const ignorePath = path.join(rootDir, '.shiloignore');
121
- if (fs.existsSync(ignorePath)) {
157
+ function loadDirRules(absDir, relBase = '') {
158
+ const base = relBase ? `${relBase}/` : '';
159
+ const rules = [];
160
+ for (const fileName of IGNORE_FILES) {
161
+ const ignorePath = path.join(absDir, fileName);
162
+ let text;
122
163
  try {
123
- const text = fs.readFileSync(ignorePath, 'utf8');
124
- lines.push(...text.split('\n'));
164
+ if (!fs.existsSync(ignorePath)) continue;
165
+ // Sin sacar el BOM (Notepad, PowerShell) el primer patrón del archivo se
166
+ // compila con un caracter invisible al frente y nunca matchea.
167
+ text = fs.readFileSync(ignorePath, 'utf8').replace(/^/, '');
125
168
  } catch (_) {
126
- /* si no se puede leer, seguimos con los defaults */
169
+ continue; // si no se puede leer, seguimos con lo que haya
170
+ }
171
+ for (const line of text.split('\n')) {
172
+ const rule = compileRule(line, base);
173
+ if (rule) rules.push(rule);
127
174
  }
128
175
  }
129
- return lines.map(compileRule).filter(Boolean);
176
+ return rules;
177
+ }
178
+
179
+ /**
180
+ * Reglas de la raíz: defaults + `.gitignore` + `.shiloignore` + extras.
181
+ * Se mantiene exportada porque es la forma de inspeccionar qué se va a ignorar
182
+ * sin recorrer nada.
183
+ */
184
+ function loadIgnoreRules(rootDir, extraLines = []) {
185
+ const rules = [];
186
+ for (const line of DEFAULT_IGNORES) {
187
+ const rule = compileRule(line);
188
+ if (rule) rules.push(rule);
189
+ }
190
+ rules.push(...loadDirRules(rootDir, ''));
191
+ for (const line of extraLines) {
192
+ const rule = compileRule(line);
193
+ if (rule) rules.push(rule);
194
+ }
195
+ return rules;
130
196
  }
131
197
 
132
198
  /**
133
199
  * Recorre `rootDir` y devuelve:
134
200
  * {
135
- * files: [{ relPath, absPath, size }], // candidatos válidos a subir
136
- * skipped: [{ relPath, reason }], // descartados (nombre/profundidad)
201
+ * files: [{ relPath, absPath, size }], // candidatos válidos a subir
202
+ * skipped: [{ relPath, reason, code }], // descartados
137
203
  * ignored: <número de entradas ignoradas por reglas>
204
+ * gitDirs: [<rutas de repos git podados>]
138
205
  * }
139
206
  *
140
207
  * No sigue symlinks (se reportan como skipped).
141
208
  */
142
209
  function walk(rootDir, { extraIgnore = [] } = {}) {
143
- const rules = loadIgnoreRules(rootDir, extraIgnore);
144
- const isIgnored = makeMatcher(rules);
210
+ const rootRules = loadIgnoreRules(rootDir, extraIgnore);
145
211
 
146
212
  const files = [];
147
213
  const skipped = [];
214
+ const gitDirs = [];
148
215
  let ignored = 0;
149
216
 
150
- function recurse(absDir, relBase, folderDepth) {
217
+ function recurse(absDir, relBase, folderDepth, inheritedRules) {
218
+ // Los ignores de este directorio se suman a los heredados y valen sólo de
219
+ // acá para abajo: por eso se copia el array en vez de mutarlo.
220
+ const rules = relBase
221
+ ? inheritedRules.concat(loadDirRules(absDir, relBase))
222
+ : inheritedRules;
223
+ const isIgnored = makeMatcher(rules);
224
+
151
225
  let entries;
152
226
  try {
153
227
  entries = fs.readdirSync(absDir, { withFileTypes: true });
154
228
  } catch (err) {
155
- skipped.push({ relPath: relBase || '.', reason: `no se pudo leer: ${err.message}` });
229
+ skipped.push({
230
+ relPath: relBase || '.',
231
+ reason: `no se pudo leer: ${err.message}`,
232
+ code: 'unreadable',
233
+ });
156
234
  return;
157
235
  }
158
236
 
@@ -160,12 +238,24 @@ function walk(rootDir, { extraIgnore = [] } = {}) {
160
238
  entries.sort((a, b) => a.name.localeCompare(b.name));
161
239
 
162
240
  for (const entry of entries) {
163
- const relPath = relBase ? `${relBase}/${entry.name}` : entry.name;
241
+ // El nombre lógico (el que viaja al repo) se normaliza igual que en el
242
+ // backend —NFC y sin espacios al borde— porque es la forma que se guarda:
243
+ // macOS devuelve NFD, y sin esto "café.md" local y "café.md" remoto no se
244
+ // reconocen como el mismo archivo. `absPath` usa el nombre tal cual lo dio
245
+ // el SO, que es el que hay que abrir.
246
+ const logicalName = entry.name.normalize('NFC').trim();
247
+ const relPath = relBase ? `${relBase}/${logicalName}` : logicalName;
164
248
  const absPath = path.join(absDir, entry.name);
165
249
  const isDir = entry.isDirectory();
166
250
 
167
251
  if (entry.isSymbolicLink()) {
168
- skipped.push({ relPath, reason: 'symlink (no soportado)' });
252
+ skipped.push({ relPath, reason: 'symlink (no soportado)', code: 'symlink' });
253
+ continue;
254
+ }
255
+
256
+ if (WIN_RESERVED.test(entry.name)) {
257
+ // No es un archivo real en Windows; leerlo devolvería el dispositivo nulo.
258
+ ignored++;
169
259
  continue;
170
260
  }
171
261
 
@@ -174,40 +264,58 @@ function walk(rootDir, { extraIgnore = [] } = {}) {
174
264
  continue;
175
265
  }
176
266
 
177
- const nameCheck = validateName(entry.name);
267
+ if (isDir && looksLikeGitDir(absPath)) {
268
+ // Todo el historial del repo, incluidos secretos de commits ya purgados
269
+ // del árbol de trabajo. Se poda entero y se avisa una sola vez.
270
+ gitDirs.push(relPath);
271
+ continue;
272
+ }
273
+
274
+ const nameCheck = validateName(logicalName);
178
275
  if (!nameCheck.ok) {
179
- skipped.push({ relPath, reason: nameCheck.reason });
276
+ skipped.push({ relPath, reason: nameCheck.reason, code: 'name' });
180
277
  continue; // si es carpeta, se poda todo el subárbol
181
278
  }
182
279
 
183
280
  if (isDir) {
184
281
  if (folderDepth + 1 > MAX_DEPTH) {
185
- skipped.push({ relPath, reason: `profundidad de carpetas > ${MAX_DEPTH}` });
282
+ skipped.push({
283
+ relPath,
284
+ reason: `profundidad de carpetas > ${MAX_DEPTH}`,
285
+ code: 'depth',
286
+ });
186
287
  continue;
187
288
  }
188
- recurse(absPath, relPath, folderDepth + 1);
289
+ recurse(absPath, relPath, folderDepth + 1, rules);
189
290
  } else if (entry.isFile()) {
190
291
  let size = 0;
292
+ let mtimeMs = 0;
191
293
  try {
192
- size = fs.statSync(absPath).size;
294
+ const st = fs.statSync(absPath);
295
+ size = st.size;
296
+ mtimeMs = st.mtimeMs;
193
297
  } catch (_) {
194
298
  /* si falla el stat, dejamos size en 0; el push lo detecta al leer */
195
299
  }
196
- files.push({ relPath, absPath, size });
300
+ files.push({ relPath, absPath, size, mtimeMs });
197
301
  }
198
302
  // otros tipos (fifo, socket, device) se ignoran silenciosamente
199
303
  }
200
304
  }
201
305
 
202
- recurse(rootDir, '', 0);
203
- return { files, skipped, ignored };
306
+ recurse(rootDir, '', 0, rootRules);
307
+ return { files, skipped, ignored, gitDirs };
204
308
  }
205
309
 
206
310
  module.exports = {
207
311
  walk,
208
312
  loadIgnoreRules,
313
+ loadDirRules,
209
314
  makeMatcher,
210
315
  compileRule,
211
316
  globToRegExp,
317
+ looksLikeGitDir,
212
318
  DEFAULT_IGNORES,
319
+ IGNORE_FILES,
320
+ WIN_RESERVED,
213
321
  };