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.
@@ -7,13 +7,32 @@ const remote = require('../remote');
7
7
  const prompt = require('../prompt');
8
8
  const ui = require('../ui');
9
9
  const sync = require('../sync');
10
+ const secrets = require('../secrets');
11
+ const pushindex = require('../pushindex');
10
12
  const { walk } = require('../walk');
11
- const { classifyContent } = require('../textguard');
13
+ const {
14
+ classifyContent,
15
+ looksBinary,
16
+ extensionOf,
17
+ BINARY_EXTENSIONS,
18
+ MAX_FILE_BYTES,
19
+ } = require('../textguard');
12
20
  const { ShiloClient } = require('../ShiloClient');
13
21
 
14
22
  const DEFAULT_BASE_URL = 'https://www.shilo.city';
15
23
  const COMMIT_BATCH = 100; // máximo de archivos por commit (límite del backend)
16
24
 
25
+ // Cuánto se lee de cada archivo en el preflight: alcanza para decidir binario
26
+ // (looksBinary mira 8000 bytes) y para escanear secretos, sin cargar el repo
27
+ // entero en memoria antes de saber si hay algo para subir.
28
+ const HEAD_BYTES = 8192;
29
+
30
+ // Cuántos ejemplos se muestran por cada motivo de salteo.
31
+ const SKIP_EXAMPLES = 5;
32
+ // A partir de cuántos salteos en una misma carpeta de primer nivel sugerimos
33
+ // ignorarla entera.
34
+ const SUGGEST_MIN = 20;
35
+
17
36
  // Mapa mínimo extensión -> mime para texto. El backend default es text/plain.
18
37
  const TEXT_MIME = {
19
38
  js: 'text/javascript', mjs: 'text/javascript', cjs: 'text/javascript',
@@ -30,8 +49,182 @@ function guessMime(name) {
30
49
  return TEXT_MIME[ext] || 'text/plain';
31
50
  }
32
51
 
52
+ const SKIP_LABELS = {
53
+ unreadable: 'no se pudieron leer',
54
+ symlink: 'symlinks (no soportados)',
55
+ name: 'nombre no permitido por Shilo',
56
+ depth: 'demasiado anidados',
57
+ binary: 'binarios o mayores a 25 MB',
58
+ };
59
+
60
+ /** Lee como mucho `n` bytes del principio del archivo. */
61
+ function readHead(absPath, n) {
62
+ const fd = fs.openSync(absPath, 'r');
63
+ try {
64
+ const buf = Buffer.allocUnsafe(n);
65
+ const read = fs.readSync(fd, buf, 0, n, 0);
66
+ return buf.subarray(0, read);
67
+ } finally {
68
+ fs.closeSync(fd);
69
+ }
70
+ }
71
+
33
72
  /**
34
- * shilo push [carpeta] [--project <uuid>] [-m <msg>] [--dry-run] [--yes] [--base-url <url>]
73
+ * Fase offline: recorre, filtra y clasifica SIN tocar la red.
74
+ *
75
+ * Todo lo que puede hacer fracasar o cancelar un push (nombres inválidos,
76
+ * binarios, secretos, "no hay nada para subir") se decide acá, antes del primer
77
+ * request. Un push que iba a terminar en nada no debería costar ni una conexión.
78
+ *
79
+ * Sólo se leen los primeros KB de cada archivo; el contenido completo se lee
80
+ * recién cuando se sabe que ese archivo se sube.
81
+ */
82
+ function preflight(dir) {
83
+ const { files, skipped, ignored, gitDirs } = walk(dir);
84
+
85
+ const uploads = [];
86
+ const skips = skipped.slice();
87
+ const findings = [];
88
+
89
+ for (const f of files) {
90
+ const name = path.basename(f.relPath);
91
+
92
+ const ext = extensionOf(name);
93
+ if (ext && BINARY_EXTENSIONS.has(ext)) {
94
+ skips.push({ relPath: f.relPath, reason: `extensión binaria .${ext}`, code: 'binary' });
95
+ continue;
96
+ }
97
+ if (f.size > MAX_FILE_BYTES) {
98
+ skips.push({ relPath: f.relPath, reason: `supera 25 MB (${f.size} bytes)`, code: 'binary' });
99
+ continue;
100
+ }
101
+
102
+ let head;
103
+ try {
104
+ head = readHead(f.absPath, HEAD_BYTES);
105
+ } catch (e) {
106
+ skips.push({ relPath: f.relPath, reason: `no se pudo leer: ${e.message}`, code: 'unreadable' });
107
+ continue;
108
+ }
109
+
110
+ if (looksBinary(head)) {
111
+ skips.push({
112
+ relPath: f.relPath,
113
+ reason: 'contenido binario (bytes no imprimibles)',
114
+ code: 'binary',
115
+ });
116
+ continue;
117
+ }
118
+
119
+ findings.push(...secrets.inspect(f.relPath, head.toString('utf8')));
120
+
121
+ uploads.push({
122
+ relPath: f.relPath,
123
+ absPath: f.absPath,
124
+ size: f.size,
125
+ mtimeMs: f.mtimeMs,
126
+ mimeType: guessMime(f.relPath),
127
+ content: null,
128
+ });
129
+ }
130
+
131
+ return { uploads, skipped: skips, ignored, gitDirs, findings };
132
+ }
133
+
134
+ /**
135
+ * Reporte agrupado de salteos. Un repo con un `venv/` adentro generaba miles de
136
+ * líneas "saltado …" que tapaban todo lo demás; acá se resume por motivo, con
137
+ * unos pocos ejemplos, y se sugiere qué ignorar.
138
+ */
139
+ function reportSkipped(skips, { verbose }) {
140
+ if (skips.length === 0) return;
141
+
142
+ if (verbose) {
143
+ for (const s of skips) ui.warn(`saltado ${ui.c.dim(s.relPath)} — ${s.reason}`);
144
+ return;
145
+ }
146
+
147
+ const byCode = new Map();
148
+ for (const s of skips) {
149
+ const code = s.code || 'otros';
150
+ if (!byCode.has(code)) byCode.set(code, []);
151
+ byCode.get(code).push(s);
152
+ }
153
+
154
+ ui.warn(`${skips.length} entrada(s) salteada(s):`);
155
+ for (const [code, list] of byCode) {
156
+ ui.info(` ${ui.c.dim('·')} ${list.length} ${SKIP_LABELS[code] || code}`);
157
+ for (const s of list.slice(0, SKIP_EXAMPLES)) {
158
+ ui.info(` ${ui.c.dim(s.relPath)} — ${s.reason}`);
159
+ }
160
+ if (list.length > SKIP_EXAMPLES) {
161
+ ui.info(ui.c.dim(` … y ${list.length - SKIP_EXAMPLES} más`));
162
+ }
163
+ }
164
+
165
+ suggestIgnore(skips);
166
+ ui.info(ui.c.dim('(--verbose muestra la lista completa)'));
167
+ }
168
+
169
+ /** Si los salteos se concentran en una carpeta de primer nivel, decirlo. */
170
+ function suggestIgnore(skips) {
171
+ const bySegment = new Map();
172
+ for (const s of skips) {
173
+ const seg = s.relPath.split('/')[0];
174
+ if (!seg || seg === s.relPath) continue; // sueltos en la raíz: no hay carpeta que ignorar
175
+ bySegment.set(seg, (bySegment.get(seg) || 0) + 1);
176
+ }
177
+
178
+ const worst = [...bySegment.entries()]
179
+ .filter(([, n]) => n >= SUGGEST_MIN)
180
+ .sort((a, b) => b[1] - a[1])
181
+ .slice(0, 3);
182
+ if (worst.length === 0) return;
183
+
184
+ ui.info('');
185
+ ui.info('Si esas carpetas no van al repo, agregalas a .shiloignore:');
186
+ for (const [seg, n] of worst) {
187
+ ui.info(` ${ui.c.bold(`${seg}/`)} ${ui.c.dim(`(${n} salteados)`)}`);
188
+ }
189
+ }
190
+
191
+ /** Muestra los hallazgos de secretos. Nunca imprime el valor detectado. */
192
+ function reportSecrets(findings) {
193
+ const groups = secrets.groupByFile(findings);
194
+ ui.error(`Posibles secretos en ${groups.length} archivo(s):`);
195
+ for (const g of groups) {
196
+ if (g.name) {
197
+ ui.info(` ${ui.c.red('!')} ${g.relPath} ${ui.c.dim(`(coincide con "${g.name}")`)}`);
198
+ }
199
+ for (const hit of g.content) {
200
+ ui.info(` ${ui.c.red('!')} ${g.relPath}:${hit.line} ${ui.c.dim(`— parece ${hit.label}`)}`);
201
+ }
202
+ }
203
+ ui.info('');
204
+ ui.info('No se subió nada. Opciones:');
205
+ ui.info(' · agregar esos archivos a .shiloignore (lo más común)');
206
+ ui.info(' · sacar el secreto del contenido y volver a intentar');
207
+ ui.info(' · si es un falso positivo, repetir con --allow-secrets');
208
+ }
209
+
210
+ /** Todas las carpetas ancestro de un conjunto de rutas de archivo. */
211
+ function neededFolders(relPaths) {
212
+ const set = new Set();
213
+ for (const p of relPaths) {
214
+ const parts = p.split('/');
215
+ parts.pop(); // el nombre del archivo
216
+ let acc = '';
217
+ for (const part of parts) {
218
+ acc = acc ? `${acc}/${part}` : part;
219
+ set.add(acc);
220
+ }
221
+ }
222
+ return set;
223
+ }
224
+
225
+ /**
226
+ * shilo push [carpeta] [--project <uuid>] [-m <msg>] [--dry-run] [--yes]
227
+ * [--allow-secrets] [--concurrency <n>] [--no-index] [--verbose]
35
228
  *
36
229
  * Replica una carpeta local a un repo de Shilo: crea archivos nuevos, actualiza
37
230
  * los que cambiaron, salta binarios/oversize/nombres inválidos, y registra todo
@@ -44,6 +237,8 @@ async function push(args) {
44
237
  return 1;
45
238
  }
46
239
 
240
+ const verbose = !!args.verbose;
241
+
47
242
  // 1) Resolver remote: --project pisa a .shilo.json.
48
243
  const savedRemote = remote.read(dir);
49
244
  const projectId =
@@ -77,108 +272,214 @@ async function push(args) {
77
272
  return 1;
78
273
  }
79
274
 
80
- // 3) Recorrer la carpeta.
81
- const { files, skipped, ignored } = walk(dir);
275
+ // 3) PREFLIGHT: todo offline. Recién después de esto se toca la red.
276
+ const { uploads, skipped, ignored, gitDirs, findings } = preflight(dir);
82
277
 
83
- if (skipped.length > 0) {
84
- for (const s of skipped) {
85
- ui.warn(`saltado ${ui.c.dim(s.relPath)} — ${s.reason}`);
278
+ if (gitDirs.length > 0) {
279
+ ui.warn(
280
+ `${gitDirs.length} repositorio(s) git podado(s) (traen todo el historial, ` +
281
+ `incluidos secretos ya borrados del árbol de trabajo):`
282
+ );
283
+ for (const g of gitDirs.slice(0, SKIP_EXAMPLES)) ui.info(` ${ui.c.dim(g)}`);
284
+ if (gitDirs.length > SKIP_EXAMPLES) {
285
+ ui.info(ui.c.dim(` … y ${gitDirs.length - SKIP_EXAMPLES} más`));
86
286
  }
87
287
  }
88
288
 
89
- // 4) Leer y clasificar (texto vs binario/oversize).
90
- const uploads = []; // { relPath, absPath, content, mimeType }
91
- const skippedBinary = [];
92
- for (const f of files) {
93
- let buf;
94
- try {
95
- buf = fs.readFileSync(f.absPath);
96
- } catch (e) {
97
- skippedBinary.push({ relPath: f.relPath, reason: `no se pudo leer: ${e.message}` });
98
- continue;
99
- }
100
- const cls = classifyContent(path.basename(f.relPath), buf);
101
- if (!cls.isText) {
102
- skippedBinary.push({ relPath: f.relPath, reason: cls.reason });
103
- continue;
104
- }
105
- uploads.push({
106
- relPath: f.relPath,
107
- absPath: f.absPath,
108
- content: cls.content,
109
- mimeType: guessMime(f.relPath),
110
- });
111
- }
289
+ reportSkipped(skipped, { verbose });
112
290
 
113
- for (const s of skippedBinary) {
114
- ui.warn(`saltado ${ui.c.dim(s.relPath)} — ${s.reason}`);
291
+ if (findings.length > 0 && !args['allow-secrets']) {
292
+ ui.info('');
293
+ reportSecrets(findings);
294
+ return 1;
295
+ }
296
+ if (findings.length > 0) {
297
+ ui.warn(
298
+ `${secrets.groupByFile(findings).length} archivo(s) con posibles secretos ` +
299
+ `se van a subir igual (--allow-secrets).`
300
+ );
115
301
  }
116
302
 
117
303
  if (uploads.length === 0) {
118
304
  ui.info('No hay archivos de texto para subir.');
119
- ui.info(
120
- ui.c.dim(
121
- `(${ignored} ignorados, ${skipped.length + skippedBinary.length} saltados)`
122
- )
123
- );
305
+ ui.info(ui.c.dim(`(${ignored} ignorados, ${skipped.length} salteados)`));
124
306
  return 0;
125
307
  }
126
308
 
309
+ // 4) Cliente + control de ritmo. `onThrottle` baja la concurrencia sola si el
310
+ // servidor empieza a responder 429, en vez de insistir hasta que corte.
311
+ const limit = sync.resolveConcurrency(args);
312
+ const throttle = sync.makeThrottle(limit);
127
313
  const client = new ShiloClient({
128
314
  baseURL,
129
315
  supabaseUrl: cfg.supabaseUrl || null,
130
316
  supabaseAnonKey: cfg.supabaseAnonKey || null,
317
+ onThrottle: () => {
318
+ if (throttle.reduce()) {
319
+ ui.warn('El servidor está limitando el ritmo (429): bajando la concurrencia.');
320
+ }
321
+ },
131
322
  });
132
323
 
133
324
  ui.info(
134
325
  `Proyecto ${ui.c.bold(projectId)} en ${ui.c.cyan(baseURL)} — ` +
135
326
  `${uploads.length} archivo(s) de texto, ${ignored} ignorado(s), ` +
136
- `${skipped.length + skippedBinary.length} saltado(s).`
327
+ `${skipped.length} salteado(s).`
137
328
  );
138
329
 
139
- // 5) Clasificar contra el remote (added / modified / unchanged) para planear.
140
- // Esto ya toca la red (getFileMeta / readFile) pero no escribe nada.
141
- // Se hace con un pool de concurrencia (mismo patrón que walkRemote en
142
- // pull/status) en vez de un archivo a la vez: es lo que hacía que un push
143
- // de muchos archivos tardara minutos por puro round-trip secuencial.
144
- // `mgr` serializa el refresh del JWT para que llamadas paralelas no
145
- // quemen el mismo refresh_token.
330
+ // 5) Planificación. `mgr` serializa el refresh del JWT para que las llamadas
331
+ // paralelas no quemen el mismo refresh_token.
146
332
  const mgr = sync.makeCredsManager(client, cfg);
147
333
  const plan = { added: [], modified: [], unchanged: [], failed: [] };
148
334
 
149
- const planResults = await sync.runPool(uploads, sync.CONCURRENCY, async (u) => {
150
- const creds = await mgr.valid();
151
- const meta = await client.getFileMeta(projectId, u.relPath, creds);
335
+ // 5a) Árbol remoto completo en un solo recorrido (un request por CARPETA).
336
+ // Antes esto era un getFileMeta por ARCHIVO: para 142 archivos en 30
337
+ // carpetas, 142 requests pasan a 30.
338
+ let tree;
339
+ try {
340
+ tree = await sync.walkRemote(client, projectId, mgr, { concurrency: throttle });
341
+ } catch (e) {
342
+ ui.error(`No se pudo leer el estado del proyecto: ${e.message}`);
343
+ return 1;
344
+ }
345
+ config.persistCredsIfChanged(cfg, mgr.current);
346
+
347
+ const treeComplete = tree.errors.length === 0;
348
+ if (!treeComplete) {
349
+ ui.warn(
350
+ `No se pudieron listar ${tree.errors.length} carpeta(s) del proyecto: ` +
351
+ `la planificación puede quedar incompleta.`
352
+ );
353
+ if (verbose) {
354
+ for (const e of tree.errors) ui.info(` ${ui.c.dim(e.folderPath || '/')} — ${e.reason}`);
355
+ }
356
+ }
357
+
358
+ const remoteByPath = new Map(tree.files.map((f) => [f.repoPath, f]));
359
+
360
+ // 5b) Índice local: un archivo cuyo size+mtime son los del último push exitoso
361
+ // no cambió, así que si además el remoto tiene ese mismo tamaño, no hace
362
+ // falta bajar contenido para compararlo. Es lo que hace que un push sin
363
+ // cambios sea casi gratis.
364
+ const useIndex = !args['no-index'];
365
+ const index = useIndex
366
+ ? pushindex.read(dir, { projectId, baseURL })
367
+ : { version: pushindex.INDEX_VERSION, projectId, baseURL, entries: {} };
368
+
369
+ const toCompare = []; // mismo tamaño, hay que mirar el contenido
370
+ const toResolve = []; // distinto tamaño: sólo falta su storage_path
371
+
372
+ for (const u of uploads) {
373
+ if (tree.folders.has(u.relPath)) {
374
+ plan.failed.push({ ...u, reason: 'ya existe una carpeta con ese nombre' });
375
+ continue;
376
+ }
152
377
 
153
- if (!meta.exists) return { kind: 'added', u };
154
- if (meta.file && meta.file.type === 'folder') {
155
- return { kind: 'failed', u, reason: 'ya existe una carpeta con ese nombre' };
378
+ const r = remoteByPath.get(u.relPath);
379
+ if (!r) {
380
+ plan.added.push(u);
381
+ continue;
156
382
  }
157
383
 
158
- // Existe como archivo: ver si cambió comparando tamaño y luego contenido.
159
- const localSize = Buffer.byteLength(u.content, 'utf8');
160
- if (meta.file && typeof meta.file.size === 'number' && meta.file.size === localSize) {
161
- const readCreds = await mgr.valid();
162
- const remoteRead = await client.readFile(projectId, u.relPath, readCreds);
163
- if (remoteRead.file && remoteRead.file.content === u.content) {
164
- return { kind: 'unchanged', u };
384
+ // Servidor viejo: el listado no trae storage_path, y el commit lo exige para
385
+ // las modificaciones. Se resuelve con un getFileMeta, como antes.
386
+ if (!r.storage_path) {
387
+ if (typeof r.size === 'number' && r.size !== u.size) {
388
+ toResolve.push({ ...u, remote: r });
389
+ } else if (useIndex && pushindex.isFresh(index, u).fresh) {
390
+ plan.unchanged.push(u);
391
+ } else {
392
+ toCompare.push({ ...u, remote: r });
165
393
  }
394
+ continue;
395
+ }
396
+
397
+ // Camino rápido: el remoto publica el SHA-256 de su contenido, así que
398
+ // "¿cambió?" se responde con aritmética local. Cero requests, cero bytes
399
+ // bajados — antes esto era descargar el archivo entero para comparar.
400
+ if (r.content_hash) {
401
+ // Mejor aún: si el índice dice que el archivo local está intacto y su hash
402
+ // ya coincide con el del servidor, ni siquiera hace falta leerlo del disco.
403
+ if (useIndex) {
404
+ const { fresh, entry } = pushindex.isFresh(index, u);
405
+ if (fresh && entry && entry.hash === r.content_hash) {
406
+ plan.unchanged.push({ ...u, remote: r });
407
+ continue;
408
+ }
409
+ }
410
+
411
+ let content;
412
+ try {
413
+ content = await loadContent(u);
414
+ } catch (e) {
415
+ plan.failed.push({ ...u, reason: e.message });
416
+ continue;
417
+ }
418
+
419
+ if (pushindex.hashContent(content) === r.content_hash) {
420
+ plan.unchanged.push({ ...u, content, remote: r });
421
+ } else {
422
+ plan.modified.push({ ...u, content, remote: r });
423
+ }
424
+ continue;
166
425
  }
167
- return { kind: 'modified', u: { ...u, remote: meta.file } };
168
- });
169
426
 
170
- for (const r of planResults) {
171
- if (r && r.__error) {
172
- plan.failed.push({ ...r.item, reason: r.__error });
427
+ // Archivo anterior a la migración 010: no tiene hash. Camino viejo, pero ya
428
+ // sin el request extra para el storage_path.
429
+ if (typeof r.size === 'number' && r.size !== u.size) {
430
+ plan.modified.push({ ...u, remote: r });
173
431
  continue;
174
432
  }
175
- if (r.kind === 'failed') plan.failed.push({ ...r.u, reason: r.reason });
176
- else plan[r.kind].push(r.u);
433
+
434
+ if (useIndex && pushindex.isFresh(index, u).fresh) {
435
+ plan.unchanged.push(u);
436
+ continue;
437
+ }
438
+ toCompare.push({ ...u, remote: r });
439
+ }
440
+
441
+ // 5c) Los que quedaron: un request cada uno, pero sólo por los candidatos a
442
+ // cambio, no por todo el repo.
443
+ const pending = toCompare.concat(toResolve);
444
+ if (pending.length > 0) {
445
+ const prog = ui.progress(pending.length, 'comparando');
446
+ const results = await sync.runPool(
447
+ pending,
448
+ throttle,
449
+ async (u) => {
450
+ // Distinto tamaño: ya sabemos que cambió, sólo hace falta la metadata
451
+ // (el commit exige el storage_path anterior).
452
+ if (typeof u.remote.size === 'number' && u.remote.size !== u.size) {
453
+ const creds = await mgr.valid();
454
+ const meta = await client.getFileMeta(projectId, u.relPath, creds);
455
+ if (!meta.exists) return { kind: 'added', u };
456
+ return { kind: 'modified', u: { ...u, remote: meta.file } };
457
+ }
458
+
459
+ const content = await loadContent(u);
460
+ const creds = await mgr.valid();
461
+ const read = await client.readFile(projectId, u.relPath, creds);
462
+ if (read.file && read.file.content === content) {
463
+ return { kind: 'unchanged', u: { ...u, content, remote: read.file } };
464
+ }
465
+ return { kind: 'modified', u: { ...u, content, remote: read.file } };
466
+ },
467
+ { onProgress: (done) => prog.update(done) }
468
+ );
469
+ prog.clear();
470
+
471
+ for (const r of results) {
472
+ if (r && r.__error) {
473
+ plan.failed.push({ ...r.item, reason: r.__error });
474
+ continue;
475
+ }
476
+ plan[r.kind].push(r.u);
477
+ }
177
478
  }
178
479
 
179
480
  config.persistCredsIfChanged(cfg, mgr.current);
180
481
 
181
- // 5b) Atribución: para los archivos que vamos a pisar, quién los tocó último.
482
+ // 5d) Atribución: para los archivos que vamos a pisar, quién los tocó último.
182
483
  // Es lo que distingue "esto lo subí yo antes" de "esto lo va a perder un
183
484
  // compañero". Best-effort: si el historial no se puede leer, seguimos.
184
485
  let attribution = { index: new Map() };
@@ -219,6 +520,7 @@ async function push(args) {
219
520
  for (const f of plan.failed) ui.error(`${f.relPath} — ${f.reason}`);
220
521
  return 1;
221
522
  }
523
+ if (useIndex && !dryRun) saveIndex(dir, index, plan, uploads);
222
524
  ui.success('Todo al día. Nada para subir.');
223
525
  return 0;
224
526
  }
@@ -265,142 +567,387 @@ async function push(args) {
265
567
  }
266
568
  }
267
569
 
268
- // 8) Ejecutar subidas. Se acumulan los cambios para el/los commit(s).
269
- const changes = []; // { file_id, action, previous_storage_path?, new_storage_path? }
270
- const ensuredCache = new Set();
570
+ // 8) Ejecutar subidas. Cada lote se commitea apenas termina (ver paso 9): si
571
+ // el push se corta a la mitad, lo ya escrito queda en el historial en vez
572
+ // de quedar en storage sin ningún commit que lo referencie.
271
573
  const failedWrites = [];
574
+ const pushedAt = Date.now();
575
+ let committed = 0;
576
+ let commitCount = 0;
577
+
578
+ /** Log por archivo: en TTY lo tapa la barra de progreso, así que va sólo con --verbose. */
579
+ const logFile = (prog, msg) => {
580
+ if (verbose) {
581
+ prog.clear();
582
+ ui.success(msg);
583
+ } else {
584
+ prog.line(`✓ ${msg}`);
585
+ }
586
+ };
587
+ const logError = (prog, msg) => {
588
+ prog.clear();
589
+ ui.error(msg);
590
+ };
272
591
 
273
- // 8a) Asegurar carpetas primero, EN SERIE. `ensuredCache` evita re-listar una
274
- // carpeta ya confirmada, pero además evita la carrera real: el backend no
275
- // siempre responde 409 de forma confiable si dos requests intentan crear
276
- // la misma carpeta al mismo tiempo, así que esta fase no se paraleliza.
277
- // Como la mayoría de los archivos comparten carpetas, el cache hace que
278
- // esto sea barato (proporcional a carpetas únicas, no a archivos).
279
- const ensureFailed = new Set();
280
- for (const u of plan.added) {
281
- try {
282
- const creds = await mgr.valid();
283
- await client.ensurePath(projectId, u.relPath, creds, ensuredCache);
284
- } catch (e) {
285
- ensureFailed.add(u.relPath);
286
- failedWrites.push({ relPath: u.relPath, reason: e.message });
287
- ui.error(`${u.relPath} — ${e.message}`);
592
+ // 8a) Carpetas faltantes, por nivel. El árbol remoto ya nos dice cuáles
593
+ // existen, así que no hace falta listar antes de crear cada una; y crear
594
+ // nivel por nivel (paralelo dentro del nivel, serie entre niveles)
595
+ // garantiza que el padre exista sin depender del 409 del backend, que no
596
+ // es confiable ante dos creaciones simultáneas de la misma carpeta.
597
+ //
598
+ // Se ejecuta a demanda: el endpoint de lote hace mkdir -p por su cuenta,
599
+ // así que estos requests sólo hacen falta en el camino archivo-por-archivo.
600
+ const failedFolders = [];
601
+ let foldersEnsured = false;
602
+
603
+ async function ensureFolders() {
604
+ if (foldersEnsured) return;
605
+ foldersEnsured = true;
606
+ if (plan.added.length === 0) return;
607
+
608
+ if (treeComplete) {
609
+ const missing = [...neededFolders(plan.added.map((u) => u.relPath))].filter(
610
+ (f) => !tree.folders.has(f)
611
+ );
612
+ const byDepth = new Map();
613
+ for (const f of missing) {
614
+ const d = f.split('/').length;
615
+ if (!byDepth.has(d)) byDepth.set(d, []);
616
+ byDepth.get(d).push(f);
617
+ }
618
+
619
+ for (const depth of [...byDepth.keys()].sort((a, b) => a - b)) {
620
+ const level = byDepth.get(depth);
621
+ const results = await sync.runPool(level, throttle, async (folderPath) => {
622
+ const parts = folderPath.split('/');
623
+ const name = parts.pop();
624
+ const creds = await mgr.valid();
625
+ await client.createFolder(projectId, name, parts.join('/'), creds);
626
+ return folderPath;
627
+ });
628
+ for (const r of results) {
629
+ if (r && r.__error) {
630
+ failedFolders.push({ folderPath: r.item, reason: r.__error });
631
+ ui.error(`carpeta ${r.item} — ${r.__error}`);
632
+ }
633
+ }
634
+ }
635
+ } else {
636
+ // Árbol incompleto: no sabemos qué existe. Volvemos al camino conservador.
637
+ const ensuredCache = new Set();
638
+ for (const u of plan.added) {
639
+ try {
640
+ const creds = await mgr.valid();
641
+ await client.ensurePath(projectId, u.relPath, creds, ensuredCache);
642
+ } catch (e) {
643
+ failedFolders.push({ folderPath: path.posix.dirname(u.relPath), reason: e.message });
644
+ ui.error(`${u.relPath} — ${e.message}`);
645
+ }
646
+ }
288
647
  }
648
+ config.persistCredsIfChanged(cfg, mgr.current);
289
649
  }
290
- config.persistCredsIfChanged(cfg, mgr.current);
291
650
 
292
- // 8b) Subidas propiamente dichas, en paralelo: acá es donde vive el grueso
293
- // del tiempo (un request por archivo), y a diferencia de ensurePath no
294
- // hay riesgo de carrera porque cada archivo tiene una ruta propia.
295
- const addedReady = plan.added.filter((u) => !ensureFailed.has(u.relPath));
296
- const addedResults = await sync.runPool(addedReady, sync.CONCURRENCY, async (u) => {
651
+ const blocked = (relPath) =>
652
+ failedFolders.some((f) => f.folderPath && relPath.startsWith(`${f.folderPath}/`));
653
+
654
+ // 8b) Subidas. El costo dominante no es el trabajo del servidor sino la
655
+ // latencia de cada round-trip, así que van agrupadas: el endpoint de lote
656
+ // escribe hasta 100 archivos por request y crea las carpetas que falten.
657
+ // El contenido se lee lote por lote, no todo de una: un repo grande no
658
+ // tiene por qué entrar entero en memoria.
659
+ const BATCH_MAX_FILES = COMMIT_BATCH; // 100: mismo techo que el commit
660
+ const BATCH_MAX_BYTES = 4 * 1024 * 1024; // el server corta en 5 MB; margen
661
+
662
+ const toUpload = [
663
+ ...plan.added.map((u) => ({ u, isNew: true })),
664
+ ...plan.modified.map((u) => ({ u, isNew: false })),
665
+ ];
666
+
667
+ // Empaquetado por cantidad Y por tamaño. Un archivo más grande que el
668
+ // presupuesto termina solo en su lote, y ahí se sube por el endpoint
669
+ // individual, que admite hasta 25 MB.
670
+ const batches = [];
671
+ {
672
+ let current = [];
673
+ let currentBytes = 0;
674
+ for (const e of toUpload) {
675
+ const bytes = typeof e.u.size === 'number' ? e.u.size : 0;
676
+ const full =
677
+ current.length >= BATCH_MAX_FILES || currentBytes + bytes > BATCH_MAX_BYTES;
678
+ if (current.length > 0 && full) {
679
+ batches.push(current);
680
+ current = [];
681
+ currentBytes = 0;
682
+ }
683
+ current.push(e);
684
+ currentBytes += bytes;
685
+ }
686
+ if (current.length > 0) batches.push(current);
687
+ }
688
+
689
+ const upProg = ui.progress(toUpload.length, 'subiendo');
690
+ let upDone = 0;
691
+ const tickUp = (n = 1) => {
692
+ upDone += n;
693
+ upProg.update(upDone);
694
+ };
695
+
696
+ const uploaded = []; // { relPath, size, mtimeMs, hash } para el índice
697
+ let batchSupported = true; // se descubre en el primer lote
698
+
699
+ /** Escribe un lote entero en un request. Devuelve un resultado por archivo. */
700
+ async function writeViaBatch(entries) {
701
+ const payload = entries.map((e) => ({
702
+ path: e.u.relPath,
703
+ content: e.content,
704
+ mimeType: e.u.mimeType,
705
+ }));
297
706
  const creds = await mgr.valid();
298
- const created = await client.createFile(projectId, u.relPath, u.content, u.mimeType, creds);
707
+ const { results } = await client.writeBatch(projectId, payload, creds);
299
708
 
300
- if (created.created && created.file) {
709
+ return entries.map((e, i) => {
710
+ const r = results[i];
711
+ if (!r || !r.ok) {
712
+ return { ok: false, reason: (r && r.error) || 'el servidor no devolvió resultado' };
713
+ }
714
+ const f = r.file || {};
715
+ const action = r.action === 'created' ? 'added' : 'modified';
716
+ const change = { file_id: f.id, action, new_storage_path: f.storage_path };
717
+ if (action === 'modified') {
718
+ // El backend devuelve el storage_path que acaba de reemplazar; sólo si
719
+ // faltara caemos a lo que sabíamos del árbol remoto.
720
+ change.previous_storage_path =
721
+ f.previous_storage_path || (e.u.remote && e.u.remote.storage_path) || f.storage_path;
722
+ }
723
+ return { ok: true, change, symbol: action === 'added' ? '+' : '~' };
724
+ });
725
+ }
726
+
727
+ /** Camino archivo-por-archivo: server sin /batch, o archivo demasiado grande. */
728
+ async function writeViaSingle(entries) {
729
+ await ensureFolders();
730
+
731
+ const outcomes = new Array(entries.length);
732
+ const queue = [];
733
+ entries.forEach((e, i) => {
734
+ if (e.isNew && blocked(e.u.relPath)) {
735
+ outcomes[i] = { ok: false, reason: 'no se pudo crear su carpeta' };
736
+ } else {
737
+ queue.push({ e, i });
738
+ }
739
+ });
740
+
741
+ const results = await sync.runPool(queue, throttle, async ({ e }) => {
742
+ const u = e.u;
743
+ if (e.isNew) {
744
+ const creds = await mgr.valid();
745
+ const created = await client.createFile(projectId, u.relPath, e.content, u.mimeType, creds);
746
+ if (created.created && created.file) {
747
+ return {
748
+ ok: true,
749
+ symbol: '+',
750
+ change: {
751
+ file_id: created.file.id,
752
+ action: 'added',
753
+ new_storage_path: created.file.storage_path,
754
+ },
755
+ };
756
+ }
757
+ // 409: apareció entremedio; caemos a update.
758
+ const writeCreds = await mgr.valid();
759
+ const upd = await client.writeFile(projectId, u.relPath, e.content, u.mimeType, writeCreds);
760
+ return {
761
+ ok: true,
762
+ symbol: '~',
763
+ change: {
764
+ file_id: upd.file.id,
765
+ action: 'modified',
766
+ previous_storage_path:
767
+ (created.file && created.file.storage_path) || upd.file.storage_path,
768
+ new_storage_path: upd.file.storage_path,
769
+ },
770
+ };
771
+ }
772
+
773
+ const creds = await mgr.valid();
774
+ const upd = await client.writeFile(projectId, u.relPath, e.content, u.mimeType, creds);
301
775
  return {
302
- u,
303
- symbol: '+',
776
+ ok: true,
777
+ symbol: '~',
304
778
  change: {
305
- file_id: created.file.id,
306
- action: 'added',
307
- new_storage_path: created.file.storage_path,
779
+ file_id: upd.file.id,
780
+ action: 'modified',
781
+ previous_storage_path: u.remote ? u.remote.storage_path : upd.file.storage_path,
782
+ new_storage_path: upd.file.storage_path,
308
783
  },
309
784
  };
310
- }
311
- // 409: apareció entremedio; caemos a update.
312
- const writeCreds = await mgr.valid();
313
- const upd = await client.writeFile(projectId, u.relPath, u.content, u.mimeType, writeCreds);
314
- return {
315
- u,
316
- symbol: '~',
317
- change: {
318
- file_id: upd.file.id,
319
- action: 'modified',
320
- previous_storage_path: (created.file && created.file.storage_path) || upd.file.storage_path,
321
- new_storage_path: upd.file.storage_path,
322
- },
323
- };
324
- });
785
+ });
325
786
 
326
- for (const r of addedResults) {
327
- if (r && r.__error) {
328
- failedWrites.push({ relPath: r.item.relPath, reason: r.__error });
329
- ui.error(`${r.item.relPath} — ${r.__error}`);
330
- continue;
787
+ // `runPool` devuelve los resultados alineados con la entrada, así que el
788
+ // índice de `queue` alcanza para volver a la posición original.
789
+ for (let k = 0; k < results.length; k += 1) {
790
+ const r = results[k];
791
+ outcomes[queue[k].i] = r && r.__error ? { ok: false, reason: r.__error } : r;
331
792
  }
332
- changes.push(r.change);
333
- ui.success(`${ui.c[r.symbol === '+' ? 'green' : 'yellow'](r.symbol)} ${r.u.relPath}`);
793
+ return outcomes;
334
794
  }
335
- config.persistCredsIfChanged(cfg, mgr.current);
336
795
 
337
- const modifiedResults = await sync.runPool(plan.modified, sync.CONCURRENCY, async (u) => {
338
- const creds = await mgr.valid();
339
- const upd = await client.writeFile(projectId, u.relPath, u.content, u.mimeType, creds);
340
- return {
341
- u,
342
- change: {
343
- file_id: upd.file.id,
344
- action: 'modified',
345
- previous_storage_path: u.remote ? u.remote.storage_path : undefined,
346
- new_storage_path: upd.file.storage_path,
347
- },
348
- };
349
- });
796
+ for (let bi = 0; bi < batches.length; bi += 1) {
797
+ const entries = batches[bi];
350
798
 
351
- for (const r of modifiedResults) {
352
- if (r && r.__error) {
353
- failedWrites.push({ relPath: r.item.relPath, reason: r.__error });
354
- ui.error(`${r.item.relPath} — ${r.__error}`);
355
- continue;
799
+ // Un fallo de lectura (dejó de ser texto, permisos) saca a ese archivo del
800
+ // lote pero no arrastra a los demás.
801
+ const loaded = [];
802
+ for (const e of entries) {
803
+ try {
804
+ e.content = await loadContent(e.u);
805
+ loaded.push(e);
806
+ } catch (err) {
807
+ failedWrites.push({ relPath: e.u.relPath, reason: err.message });
808
+ logError(upProg, `${e.u.relPath} — ${err.message}`);
809
+ tickUp();
810
+ }
356
811
  }
357
- changes.push(r.change);
358
- ui.success(`${ui.c.yellow('~')} ${r.u.relPath}`);
359
- }
360
- config.persistCredsIfChanged(cfg, mgr.current);
812
+ if (loaded.length === 0) continue;
361
813
 
362
- // 9) Commit(s) en lotes de <=100.
363
- let committed = 0;
364
- if (changes.length > 0) {
365
- const total = changes.length;
366
- const nBatches = Math.ceil(total / COMMIT_BATCH);
367
- for (let i = 0; i < total; i += COMMIT_BATCH) {
368
- const batch = changes.slice(i, i + COMMIT_BATCH);
369
- const partNo = Math.floor(i / COMMIT_BATCH) + 1;
370
- const msg = buildMessage(message, batch.length, total, partNo, nBatches, card);
814
+ const oversize =
815
+ loaded.length === 1 && Buffer.byteLength(loaded[0].content, 'utf8') > BATCH_MAX_BYTES;
816
+ let outcomes = null;
817
+
818
+ if (batchSupported && !oversize) {
819
+ try {
820
+ outcomes = await writeViaBatch(loaded);
821
+ } catch (err) {
822
+ if (err.notSupported) {
823
+ batchSupported = false;
824
+ upProg.clear();
825
+ ui.warn('El servidor no acepta escritura por lotes; se sube archivo por archivo.');
826
+ } else {
827
+ // Falla del request entero (red, 5xx, permisos): ningún archivo del
828
+ // lote se escribió.
829
+ for (const e of loaded) {
830
+ failedWrites.push({ relPath: e.u.relPath, reason: err.message });
831
+ logError(upProg, `${e.u.relPath} — ${err.message}`);
832
+ }
833
+ tickUp(loaded.length);
834
+ continue;
835
+ }
836
+ }
837
+ }
838
+
839
+ if (!outcomes) outcomes = await writeViaSingle(loaded);
840
+ config.persistCredsIfChanged(cfg, mgr.current);
841
+
842
+ const batchChanges = [];
843
+ for (let i = 0; i < loaded.length; i += 1) {
844
+ const e = loaded[i];
845
+ const o = outcomes[i];
846
+ if (!o || !o.ok) {
847
+ const reason = (o && o.reason) || 'error desconocido';
848
+ failedWrites.push({ relPath: e.u.relPath, reason });
849
+ logError(upProg, `${e.u.relPath} — ${reason}`);
850
+ continue;
851
+ }
852
+ batchChanges.push(o.change);
853
+ uploaded.push({ ...e.u, hash: pushindex.hashContent(e.content) });
854
+ logFile(upProg, `${ui.c[o.symbol === '+' ? 'green' : 'yellow'](o.symbol)} ${e.u.relPath}`);
855
+ }
856
+ tickUp(loaded.length);
857
+
858
+ // 9) Commit del lote, apenas termina de escribirse. Juntar todos los cambios
859
+ // para un commit final ahorraba requests, pero dejaba una ventana larga
860
+ // en la que los archivos ya estaban escritos y ningún commit los
861
+ // mencionaba; si el push moría ahí, el historial quedaba mintiendo.
862
+ if (batchChanges.length > 0) {
863
+ const partNo = bi + 1;
864
+ const msg = buildMessage(
865
+ message,
866
+ batchChanges.length,
867
+ toUpload.length,
868
+ partNo,
869
+ batches.length,
870
+ card
871
+ );
371
872
  try {
372
873
  const creds = await mgr.valid();
373
- await client.createCommit(projectId, msg, batch, creds);
374
- committed += batch.length;
874
+ await client.createCommit(projectId, msg, batchChanges, creds);
875
+ committed += batchChanges.length;
876
+ commitCount += 1;
375
877
  config.persistCredsIfChanged(cfg, mgr.current);
376
878
  } catch (e) {
377
- ui.error(`Fallo al crear el commit (lote ${partNo}/${nBatches}): ${e.message}`);
879
+ ui.error(`Fallo al crear el commit (lote ${partNo}/${batches.length}): ${e.message}`);
378
880
  failedWrites.push({ relPath: `<commit lote ${partNo}>`, reason: e.message });
379
881
  }
380
882
  }
883
+
884
+ // El contenido ya se hasheó y se subió: soltarlo mantiene el pico de memoria
885
+ // atado al tamaño del lote y no al del repo.
886
+ for (const e of loaded) {
887
+ e.content = null;
888
+ e.u.content = null;
889
+ }
381
890
  }
891
+ upProg.clear();
382
892
 
383
893
  config.persistCredsIfChanged(cfg, mgr.current);
384
894
 
385
- // 10) Anotar la sincronización: `status` la usa después para saber qué del
386
- // remoto es posterior a lo último que subimos.
387
- if (failedWrites.length === 0) {
895
+ // 10) Índice local: lo que subimos bien y lo que ya estaba igual. El próximo
896
+ // push arranca sabiendo qué no hace falta ni leer.
897
+ if (useIndex) {
898
+ for (const u of uploaded) {
899
+ pushindex.record(index, u, pushedAt);
900
+ }
901
+ saveIndex(dir, index, plan, uploads, pushedAt);
902
+ }
903
+
904
+ // 11) Anotar la sincronización: `status` la usa después para saber qué del
905
+ // remoto es posterior a lo último que subimos. Sólo si TODO salió bien:
906
+ // con archivos fallados, marcar "sincronizado" haría que status mienta.
907
+ if (failedWrites.length === 0 && plan.failed.length === 0) {
388
908
  remote.touchSync(dir, { projectId, baseURL });
389
909
  }
390
910
 
391
- // 11) Resumen final.
911
+ // 12) Resumen final.
392
912
  ui.info('');
393
- const nCommits = Math.ceil(changes.length / COMMIT_BATCH);
394
- if (failedWrites.length === 0) {
395
- ui.success(
396
- `Push completo: ${committed} archivo(s) en ${nCommits} commit(s).`
397
- );
913
+ if (failedWrites.length === 0 && plan.failed.length === 0) {
914
+ ui.success(`Push completo: ${committed} archivo(s) en ${commitCount} commit(s).`);
398
915
  return 0;
399
916
  }
400
- ui.warn(`Push con problemas: ${failedWrites.length} archivo(s)/commit(s) fallaron.`);
917
+ for (const f of plan.failed) ui.error(`${f.relPath} — ${f.reason}`);
918
+ ui.warn(
919
+ `Push con problemas: ${failedWrites.length + plan.failed.length} archivo(s)/commit(s) fallaron.`
920
+ );
401
921
  return 1;
402
922
  }
403
923
 
924
+ /**
925
+ * Lee el contenido completo de un archivo, cacheado en la propia entrada.
926
+ * Re-valida que siga siendo texto: entre el preflight y la subida pudo cambiar.
927
+ */
928
+ async function loadContent(u) {
929
+ if (u.content != null) return u.content;
930
+ const buf = await fs.promises.readFile(u.absPath);
931
+ const cls = classifyContent(path.basename(u.relPath), buf);
932
+ if (!cls.isText) throw new Error(cls.reason);
933
+ u.content = cls.content;
934
+ return u.content;
935
+ }
936
+
937
+ /**
938
+ * Registra en el índice los archivos que ya estaban idénticos (para no volver a
939
+ * compararlos), saca los que ya no existen y lo escribe.
940
+ */
941
+ function saveIndex(dir, index, plan, uploads, pushedAt = Date.now()) {
942
+ for (const u of plan.unchanged) {
943
+ if (u.content != null) {
944
+ pushindex.record(index, { ...u, hash: pushindex.hashContent(u.content) }, pushedAt);
945
+ }
946
+ }
947
+ pushindex.prune(index, new Set(uploads.map((u) => u.relPath)));
948
+ pushindex.write(dir, index);
949
+ }
950
+
404
951
  /**
405
952
  * Arma el mensaje del commit. Si vino `--card`, se agrega como trailer
406
953
  * `[card: <id>]` al final: queda visible en el historial y es fácil de grepear