shilo-cli 0.3.2 → 0.6.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,33 @@ 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
+ binaryMimeType,
17
+ extensionOf,
18
+ BINARY_EXTENSIONS,
19
+ MAX_FILE_BYTES,
20
+ } = require('../textguard');
12
21
  const { ShiloClient } = require('../ShiloClient');
13
22
 
14
23
  const DEFAULT_BASE_URL = 'https://www.shilo.city';
15
24
  const COMMIT_BATCH = 100; // máximo de archivos por commit (límite del backend)
16
25
 
26
+ // Cuánto se lee de cada archivo en el preflight: alcanza para decidir binario
27
+ // (looksBinary mira 8000 bytes) y para escanear secretos, sin cargar el repo
28
+ // entero en memoria antes de saber si hay algo para subir.
29
+ const HEAD_BYTES = 8192;
30
+
31
+ // Cuántos ejemplos se muestran por cada motivo de salteo.
32
+ const SKIP_EXAMPLES = 5;
33
+ // A partir de cuántos salteos en una misma carpeta de primer nivel sugerimos
34
+ // ignorarla entera.
35
+ const SUGGEST_MIN = 20;
36
+
17
37
  // Mapa mínimo extensión -> mime para texto. El backend default es text/plain.
18
38
  const TEXT_MIME = {
19
39
  js: 'text/javascript', mjs: 'text/javascript', cjs: 'text/javascript',
@@ -30,8 +50,192 @@ function guessMime(name) {
30
50
  return TEXT_MIME[ext] || 'text/plain';
31
51
  }
32
52
 
53
+ const SKIP_LABELS = {
54
+ unreadable: 'no se pudieron leer',
55
+ symlink: 'symlinks (no soportados)',
56
+ name: 'nombre no permitido por Shilo',
57
+ depth: 'demasiado anidados',
58
+ // Los binarios ya NO se saltean: desde la Etapa 2 suben por URL firmada. Este
59
+ // motivo quedó sólo para lo que supera el techo de tamaño.
60
+ size: `mayores a ${MAX_FILE_BYTES / (1024 * 1024)} MB`,
61
+ };
62
+
63
+ /** Lee como mucho `n` bytes del principio del archivo. */
64
+ function readHead(absPath, n) {
65
+ const fd = fs.openSync(absPath, 'r');
66
+ try {
67
+ const buf = Buffer.allocUnsafe(n);
68
+ const read = fs.readSync(fd, buf, 0, n, 0);
69
+ return buf.subarray(0, read);
70
+ } finally {
71
+ fs.closeSync(fd);
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Fase offline: recorre, filtra y clasifica SIN tocar la red.
77
+ *
78
+ * Todo lo que puede hacer fracasar o cancelar un push (nombres inválidos,
79
+ * binarios, secretos, "no hay nada para subir") se decide acá, antes del primer
80
+ * request. Un push que iba a terminar en nada no debería costar ni una conexión.
81
+ *
82
+ * Sólo se leen los primeros KB de cada archivo; el contenido completo se lee
83
+ * recién cuando se sabe que ese archivo se sube.
84
+ *
85
+ * Desde la Etapa 2 esta fase ya no descarta binarios: los MARCA. Un archivo con
86
+ * `binary: true` sube por URL firmada (`client.uploadBinary`) en vez de por el
87
+ * lote JSON, porque el lote hashea el contenido como utf8 y ahí un binario se
88
+ * corrompe antes de que nadie pueda notarlo. Lo único que sigue siendo motivo de
89
+ * salteo es superar el techo de tamaño.
90
+ */
91
+ function preflight(dir) {
92
+ const { files, skipped, ignored, gitDirs } = walk(dir);
93
+
94
+ const uploads = [];
95
+ const skips = skipped.slice();
96
+ const findings = [];
97
+
98
+ for (const f of files) {
99
+ const name = path.basename(f.relPath);
100
+
101
+ if (f.size > MAX_FILE_BYTES) {
102
+ skips.push({
103
+ relPath: f.relPath,
104
+ reason: `supera ${MAX_FILE_BYTES / (1024 * 1024)} MB (${f.size} bytes)`,
105
+ code: 'size',
106
+ });
107
+ continue;
108
+ }
109
+
110
+ const ext = extensionOf(name);
111
+ let binary = Boolean(ext && BINARY_EXTENSIONS.has(ext));
112
+ let head = null;
113
+
114
+ // Un archivo cuya extensión ya dice que es binario no hace falta leerlo para
115
+ // saberlo, y leerlo sólo serviría para escanearle secretos — que en un JPEG
116
+ // son ruido.
117
+ if (!binary) {
118
+ try {
119
+ head = readHead(f.absPath, HEAD_BYTES);
120
+ } catch (e) {
121
+ skips.push({ relPath: f.relPath, reason: `no se pudo leer: ${e.message}`, code: 'unreadable' });
122
+ continue;
123
+ }
124
+ binary = looksBinary(head);
125
+ }
126
+
127
+ if (!binary) {
128
+ findings.push(...secrets.inspect(f.relPath, head.toString('utf8')));
129
+ }
130
+
131
+ uploads.push({
132
+ relPath: f.relPath,
133
+ absPath: f.absPath,
134
+ size: f.size,
135
+ mtimeMs: f.mtimeMs,
136
+ binary,
137
+ mimeType: binary ? binaryMimeType(name) : guessMime(f.relPath),
138
+ content: null,
139
+ });
140
+ }
141
+
142
+ return { uploads, skipped: skips, ignored, gitDirs, findings };
143
+ }
144
+
145
+ /**
146
+ * Reporte agrupado de salteos. Un repo con un `venv/` adentro generaba miles de
147
+ * líneas "saltado …" que tapaban todo lo demás; acá se resume por motivo, con
148
+ * unos pocos ejemplos, y se sugiere qué ignorar.
149
+ */
150
+ function reportSkipped(skips, { verbose }) {
151
+ if (skips.length === 0) return;
152
+
153
+ if (verbose) {
154
+ for (const s of skips) ui.warn(`saltado ${ui.c.dim(s.relPath)} — ${s.reason}`);
155
+ return;
156
+ }
157
+
158
+ const byCode = new Map();
159
+ for (const s of skips) {
160
+ const code = s.code || 'otros';
161
+ if (!byCode.has(code)) byCode.set(code, []);
162
+ byCode.get(code).push(s);
163
+ }
164
+
165
+ ui.warn(`${skips.length} entrada(s) salteada(s):`);
166
+ for (const [code, list] of byCode) {
167
+ ui.info(` ${ui.c.dim('·')} ${list.length} ${SKIP_LABELS[code] || code}`);
168
+ for (const s of list.slice(0, SKIP_EXAMPLES)) {
169
+ ui.info(` ${ui.c.dim(s.relPath)} — ${s.reason}`);
170
+ }
171
+ if (list.length > SKIP_EXAMPLES) {
172
+ ui.info(ui.c.dim(` … y ${list.length - SKIP_EXAMPLES} más`));
173
+ }
174
+ }
175
+
176
+ suggestIgnore(skips);
177
+ ui.info(ui.c.dim('(--verbose muestra la lista completa)'));
178
+ }
179
+
180
+ /** Si los salteos se concentran en una carpeta de primer nivel, decirlo. */
181
+ function suggestIgnore(skips) {
182
+ const bySegment = new Map();
183
+ for (const s of skips) {
184
+ const seg = s.relPath.split('/')[0];
185
+ if (!seg || seg === s.relPath) continue; // sueltos en la raíz: no hay carpeta que ignorar
186
+ bySegment.set(seg, (bySegment.get(seg) || 0) + 1);
187
+ }
188
+
189
+ const worst = [...bySegment.entries()]
190
+ .filter(([, n]) => n >= SUGGEST_MIN)
191
+ .sort((a, b) => b[1] - a[1])
192
+ .slice(0, 3);
193
+ if (worst.length === 0) return;
194
+
195
+ ui.info('');
196
+ ui.info('Si esas carpetas no van al repo, agregalas a .shiloignore:');
197
+ for (const [seg, n] of worst) {
198
+ ui.info(` ${ui.c.bold(`${seg}/`)} ${ui.c.dim(`(${n} salteados)`)}`);
199
+ }
200
+ }
201
+
202
+ /** Muestra los hallazgos de secretos. Nunca imprime el valor detectado. */
203
+ function reportSecrets(findings) {
204
+ const groups = secrets.groupByFile(findings);
205
+ ui.error(`Posibles secretos en ${groups.length} archivo(s):`);
206
+ for (const g of groups) {
207
+ if (g.name) {
208
+ ui.info(` ${ui.c.red('!')} ${g.relPath} ${ui.c.dim(`(coincide con "${g.name}")`)}`);
209
+ }
210
+ for (const hit of g.content) {
211
+ ui.info(` ${ui.c.red('!')} ${g.relPath}:${hit.line} ${ui.c.dim(`— parece ${hit.label}`)}`);
212
+ }
213
+ }
214
+ ui.info('');
215
+ ui.info('No se subió nada. Opciones:');
216
+ ui.info(' · agregar esos archivos a .shiloignore (lo más común)');
217
+ ui.info(' · sacar el secreto del contenido y volver a intentar');
218
+ ui.info(' · si es un falso positivo, repetir con --allow-secrets');
219
+ }
220
+
221
+ /** Todas las carpetas ancestro de un conjunto de rutas de archivo. */
222
+ function neededFolders(relPaths) {
223
+ const set = new Set();
224
+ for (const p of relPaths) {
225
+ const parts = p.split('/');
226
+ parts.pop(); // el nombre del archivo
227
+ let acc = '';
228
+ for (const part of parts) {
229
+ acc = acc ? `${acc}/${part}` : part;
230
+ set.add(acc);
231
+ }
232
+ }
233
+ return set;
234
+ }
235
+
33
236
  /**
34
- * shilo push [carpeta] [--project <uuid>] [-m <msg>] [--dry-run] [--yes] [--base-url <url>]
237
+ * shilo push [carpeta] [--project <uuid>] [-m <msg>] [--dry-run] [--yes]
238
+ * [--allow-secrets] [--concurrency <n>] [--no-index] [--verbose]
35
239
  *
36
240
  * Replica una carpeta local a un repo de Shilo: crea archivos nuevos, actualiza
37
241
  * los que cambiaron, salta binarios/oversize/nombres inválidos, y registra todo
@@ -44,6 +248,8 @@ async function push(args) {
44
248
  return 1;
45
249
  }
46
250
 
251
+ const verbose = !!args.verbose;
252
+
47
253
  // 1) Resolver remote: --project pisa a .shilo.json.
48
254
  const savedRemote = remote.read(dir);
49
255
  const projectId =
@@ -77,108 +283,228 @@ async function push(args) {
77
283
  return 1;
78
284
  }
79
285
 
80
- // 3) Recorrer la carpeta.
81
- const { files, skipped, ignored } = walk(dir);
286
+ // 3) PREFLIGHT: todo offline. Recién después de esto se toca la red.
287
+ const { uploads, skipped, ignored, gitDirs, findings } = preflight(dir);
82
288
 
83
- if (skipped.length > 0) {
84
- for (const s of skipped) {
85
- ui.warn(`saltado ${ui.c.dim(s.relPath)} — ${s.reason}`);
289
+ if (gitDirs.length > 0) {
290
+ ui.warn(
291
+ `${gitDirs.length} repositorio(s) git podado(s) (traen todo el historial, ` +
292
+ `incluidos secretos ya borrados del árbol de trabajo):`
293
+ );
294
+ for (const g of gitDirs.slice(0, SKIP_EXAMPLES)) ui.info(` ${ui.c.dim(g)}`);
295
+ if (gitDirs.length > SKIP_EXAMPLES) {
296
+ ui.info(ui.c.dim(` … y ${gitDirs.length - SKIP_EXAMPLES} más`));
86
297
  }
87
298
  }
88
299
 
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
- }
300
+ reportSkipped(skipped, { verbose });
112
301
 
113
- for (const s of skippedBinary) {
114
- ui.warn(`saltado ${ui.c.dim(s.relPath)} — ${s.reason}`);
302
+ if (findings.length > 0 && !args['allow-secrets']) {
303
+ ui.info('');
304
+ reportSecrets(findings);
305
+ return 1;
306
+ }
307
+ if (findings.length > 0) {
308
+ ui.warn(
309
+ `${secrets.groupByFile(findings).length} archivo(s) con posibles secretos ` +
310
+ `se van a subir igual (--allow-secrets).`
311
+ );
115
312
  }
116
313
 
117
314
  if (uploads.length === 0) {
118
- 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
- );
315
+ ui.info('No hay archivos para subir.');
316
+ ui.info(ui.c.dim(`(${ignored} ignorados, ${skipped.length} salteados)`));
124
317
  return 0;
125
318
  }
126
319
 
320
+ // 4) Cliente + control de ritmo. `onThrottle` baja la concurrencia sola si el
321
+ // servidor empieza a responder 429, en vez de insistir hasta que corte.
322
+ const limit = sync.resolveConcurrency(args);
323
+ const throttle = sync.makeThrottle(limit);
127
324
  const client = new ShiloClient({
128
325
  baseURL,
129
326
  supabaseUrl: cfg.supabaseUrl || null,
130
327
  supabaseAnonKey: cfg.supabaseAnonKey || null,
328
+ onThrottle: () => {
329
+ if (throttle.reduce()) {
330
+ ui.warn('El servidor está limitando el ritmo (429): bajando la concurrencia.');
331
+ }
332
+ },
131
333
  });
132
334
 
133
335
  ui.info(
134
336
  `Proyecto ${ui.c.bold(projectId)} en ${ui.c.cyan(baseURL)} — ` +
135
- `${uploads.length} archivo(s) de texto, ${ignored} ignorado(s), ` +
136
- `${skipped.length + skippedBinary.length} saltado(s).`
337
+ `${uploads.length} archivo(s) (${uploads.filter((u) => u.binary).length} binario(s)), ` +
338
+ `${ignored} ignorado(s), ` +
339
+ `${skipped.length} salteado(s).`
137
340
  );
138
341
 
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.
342
+ // 5) Planificación. `mgr` serializa el refresh del JWT para que las llamadas
343
+ // paralelas no quemen el mismo refresh_token.
146
344
  const mgr = sync.makeCredsManager(client, cfg);
147
345
  const plan = { added: [], modified: [], unchanged: [], failed: [] };
148
346
 
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);
347
+ // 5a) Árbol remoto completo en un solo recorrido (un request por CARPETA).
348
+ // Antes esto era un getFileMeta por ARCHIVO: para 142 archivos en 30
349
+ // carpetas, 142 requests pasan a 30.
350
+ let tree;
351
+ try {
352
+ tree = await sync.walkRemote(client, projectId, mgr, { concurrency: throttle });
353
+ } catch (e) {
354
+ ui.error(`No se pudo leer el estado del proyecto: ${e.message}`);
355
+ return 1;
356
+ }
357
+ config.persistCredsIfChanged(cfg, mgr.current);
358
+
359
+ const treeComplete = tree.errors.length === 0;
360
+ if (!treeComplete) {
361
+ ui.warn(
362
+ `No se pudieron listar ${tree.errors.length} carpeta(s) del proyecto: ` +
363
+ `la planificación puede quedar incompleta.`
364
+ );
365
+ if (verbose) {
366
+ for (const e of tree.errors) ui.info(` ${ui.c.dim(e.folderPath || '/')} — ${e.reason}`);
367
+ }
368
+ }
369
+
370
+ const remoteByPath = new Map(tree.files.map((f) => [f.repoPath, f]));
371
+
372
+ // 5b) Índice local: un archivo cuyo size+mtime son los del último push exitoso
373
+ // no cambió, así que si además el remoto tiene ese mismo tamaño, no hace
374
+ // falta bajar contenido para compararlo. Es lo que hace que un push sin
375
+ // cambios sea casi gratis.
376
+ const useIndex = !args['no-index'];
377
+ const index = useIndex
378
+ ? pushindex.read(dir, { projectId, baseURL })
379
+ : { version: pushindex.INDEX_VERSION, projectId, baseURL, entries: {} };
152
380
 
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' };
381
+ const toCompare = []; // mismo tamaño, hay que mirar el contenido
382
+ const toResolve = []; // distinto tamaño: sólo falta su storage_path
383
+
384
+ for (const u of uploads) {
385
+ if (tree.folders.has(u.relPath)) {
386
+ plan.failed.push({ ...u, reason: 'ya existe una carpeta con ese nombre' });
387
+ continue;
156
388
  }
157
389
 
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 };
390
+ const r = remoteByPath.get(u.relPath);
391
+ if (!r) {
392
+ plan.added.push(u);
393
+ continue;
394
+ }
395
+
396
+ // Servidor viejo: el listado no trae storage_path, y el commit lo exige para
397
+ // las modificaciones. Se resuelve con un getFileMeta, como antes.
398
+ if (!r.storage_path) {
399
+ if (typeof r.size === 'number' && r.size !== u.size) {
400
+ toResolve.push({ ...u, remote: r });
401
+ } else if (useIndex && pushindex.isFresh(index, u).fresh) {
402
+ plan.unchanged.push(u);
403
+ } else {
404
+ toCompare.push({ ...u, remote: r });
165
405
  }
406
+ continue;
166
407
  }
167
- return { kind: 'modified', u: { ...u, remote: meta.file } };
168
- });
169
408
 
170
- for (const r of planResults) {
171
- if (r && r.__error) {
172
- plan.failed.push({ ...r.item, reason: r.__error });
409
+ // Camino rápido: el remoto publica el SHA-256 de su contenido, así que
410
+ // "¿cambió?" se responde con aritmética local. Cero requests, cero bytes
411
+ // bajados — antes esto era descargar el archivo entero para comparar.
412
+ if (r.content_hash) {
413
+ // Mejor aún: si el índice dice que el archivo local está intacto y su hash
414
+ // ya coincide con el del servidor, ni siquiera hace falta leerlo del disco.
415
+ if (useIndex) {
416
+ const { fresh, entry } = pushindex.isFresh(index, u);
417
+ if (fresh && entry && entry.hash === r.content_hash) {
418
+ plan.unchanged.push({ ...u, remote: r });
419
+ continue;
420
+ }
421
+ }
422
+
423
+ let content;
424
+ try {
425
+ content = await loadContent(u);
426
+ } catch (e) {
427
+ plan.failed.push({ ...u, reason: e.message });
428
+ continue;
429
+ }
430
+
431
+ if (pushindex.hashContent(content) === r.content_hash) {
432
+ plan.unchanged.push({ ...u, content, remote: r });
433
+ } else {
434
+ plan.modified.push({ ...u, content, remote: r });
435
+ }
173
436
  continue;
174
437
  }
175
- if (r.kind === 'failed') plan.failed.push({ ...r.u, reason: r.reason });
176
- else plan[r.kind].push(r.u);
438
+
439
+ // Archivo anterior a la migración 010: no tiene hash. Camino viejo, pero ya
440
+ // sin el request extra para el storage_path.
441
+ if (typeof r.size === 'number' && r.size !== u.size) {
442
+ plan.modified.push({ ...u, remote: r });
443
+ continue;
444
+ }
445
+
446
+ if (useIndex && pushindex.isFresh(index, u).fresh) {
447
+ plan.unchanged.push(u);
448
+ continue;
449
+ }
450
+ toCompare.push({ ...u, remote: r });
451
+ }
452
+
453
+ // 5c) Los que quedaron: un request cada uno, pero sólo por los candidatos a
454
+ // cambio, no por todo el repo.
455
+ const pending = toCompare.concat(toResolve);
456
+ if (pending.length > 0) {
457
+ const prog = ui.progress(pending.length, 'comparando');
458
+ const results = await sync.runPool(
459
+ pending,
460
+ throttle,
461
+ async (u) => {
462
+ // Distinto tamaño: ya sabemos que cambió, sólo hace falta la metadata
463
+ // (el commit exige el storage_path anterior).
464
+ if (typeof u.remote.size === 'number' && u.remote.size !== u.size) {
465
+ const creds = await mgr.valid();
466
+ const meta = await client.getFileMeta(projectId, u.relPath, creds);
467
+ if (!meta.exists) return { kind: 'added', u };
468
+ return { kind: 'modified', u: { ...u, remote: meta.file } };
469
+ }
470
+
471
+ const content = await loadContent(u);
472
+
473
+ // Un binario no se compara bajando el contenido: el GET devuelve una URL
474
+ // firmada, no bytes, justamente para no arrastrar el archivo por el
475
+ // payload de la función. Sin `content_hash` remoto no queda con qué
476
+ // comparar, así que se asume modificado. El servidor deduplica: si el
477
+ // contenido es el mismo, el blob ya existe y no se transfiere un byte.
478
+ if (u.binary) {
479
+ const creds = await mgr.valid();
480
+ const meta = await client.getFileMeta(projectId, u.relPath, creds);
481
+ if (!meta.exists) return { kind: 'added', u: { ...u, content } };
482
+ return { kind: 'modified', u: { ...u, content, remote: meta.file } };
483
+ }
484
+
485
+ const creds = await mgr.valid();
486
+ const read = await client.readFile(projectId, u.relPath, creds);
487
+ if (read.file && read.file.content === content) {
488
+ return { kind: 'unchanged', u: { ...u, content, remote: read.file } };
489
+ }
490
+ return { kind: 'modified', u: { ...u, content, remote: read.file } };
491
+ },
492
+ { onProgress: (done) => prog.update(done) }
493
+ );
494
+ prog.clear();
495
+
496
+ for (const r of results) {
497
+ if (r && r.__error) {
498
+ plan.failed.push({ ...r.item, reason: r.__error });
499
+ continue;
500
+ }
501
+ plan[r.kind].push(r.u);
502
+ }
177
503
  }
178
504
 
179
505
  config.persistCredsIfChanged(cfg, mgr.current);
180
506
 
181
- // 5b) Atribución: para los archivos que vamos a pisar, quién los tocó último.
507
+ // 5d) Atribución: para los archivos que vamos a pisar, quién los tocó último.
182
508
  // Es lo que distingue "esto lo subí yo antes" de "esto lo va a perder un
183
509
  // compañero". Best-effort: si el historial no se puede leer, seguimos.
184
510
  let attribution = { index: new Map() };
@@ -219,6 +545,7 @@ async function push(args) {
219
545
  for (const f of plan.failed) ui.error(`${f.relPath} — ${f.reason}`);
220
546
  return 1;
221
547
  }
548
+ if (useIndex && !dryRun) saveIndex(dir, index, plan, uploads);
222
549
  ui.success('Todo al día. Nada para subir.');
223
550
  return 0;
224
551
  }
@@ -265,142 +592,490 @@ async function push(args) {
265
592
  }
266
593
  }
267
594
 
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();
595
+ // 8) Ejecutar subidas. Cada lote se commitea apenas termina (ver paso 9): si
596
+ // el push se corta a la mitad, lo ya escrito queda en el historial en vez
597
+ // de quedar en storage sin ningún commit que lo referencie.
271
598
  const failedWrites = [];
599
+ const pushedAt = Date.now();
600
+ let committed = 0;
601
+ let commitCount = 0;
272
602
 
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}`);
603
+ /** Log por archivo: en TTY lo tapa la barra de progreso, así que va sólo con --verbose. */
604
+ const logFile = (prog, msg) => {
605
+ if (verbose) {
606
+ prog.clear();
607
+ ui.success(msg);
608
+ } else {
609
+ prog.line(`✓ ${msg}`);
288
610
  }
611
+ };
612
+ const logError = (prog, msg) => {
613
+ prog.clear();
614
+ ui.error(msg);
615
+ };
616
+
617
+ // 8a) Carpetas faltantes, por nivel. El árbol remoto ya nos dice cuáles
618
+ // existen, así que no hace falta listar antes de crear cada una; y crear
619
+ // nivel por nivel (paralelo dentro del nivel, serie entre niveles)
620
+ // garantiza que el padre exista sin depender del 409 del backend, que no
621
+ // es confiable ante dos creaciones simultáneas de la misma carpeta.
622
+ //
623
+ // Se ejecuta a demanda: el endpoint de lote hace mkdir -p por su cuenta,
624
+ // así que estos requests sólo hacen falta en el camino archivo-por-archivo.
625
+ const failedFolders = [];
626
+ let foldersEnsured = false;
627
+
628
+ async function ensureFolders() {
629
+ if (foldersEnsured) return;
630
+ foldersEnsured = true;
631
+ if (plan.added.length === 0) return;
632
+
633
+ if (treeComplete) {
634
+ const missing = [...neededFolders(plan.added.map((u) => u.relPath))].filter(
635
+ (f) => !tree.folders.has(f)
636
+ );
637
+ const byDepth = new Map();
638
+ for (const f of missing) {
639
+ const d = f.split('/').length;
640
+ if (!byDepth.has(d)) byDepth.set(d, []);
641
+ byDepth.get(d).push(f);
642
+ }
643
+
644
+ for (const depth of [...byDepth.keys()].sort((a, b) => a - b)) {
645
+ const level = byDepth.get(depth);
646
+ const results = await sync.runPool(level, throttle, async (folderPath) => {
647
+ const parts = folderPath.split('/');
648
+ const name = parts.pop();
649
+ const creds = await mgr.valid();
650
+ await client.createFolder(projectId, name, parts.join('/'), creds);
651
+ return folderPath;
652
+ });
653
+ for (const r of results) {
654
+ if (r && r.__error) {
655
+ failedFolders.push({ folderPath: r.item, reason: r.__error });
656
+ ui.error(`carpeta ${r.item} — ${r.__error}`);
657
+ }
658
+ }
659
+ }
660
+ } else {
661
+ // Árbol incompleto: no sabemos qué existe. Volvemos al camino conservador.
662
+ const ensuredCache = new Set();
663
+ for (const u of plan.added) {
664
+ try {
665
+ const creds = await mgr.valid();
666
+ await client.ensurePath(projectId, u.relPath, creds, ensuredCache);
667
+ } catch (e) {
668
+ failedFolders.push({ folderPath: path.posix.dirname(u.relPath), reason: e.message });
669
+ ui.error(`${u.relPath} — ${e.message}`);
670
+ }
671
+ }
672
+ }
673
+ config.persistCredsIfChanged(cfg, mgr.current);
289
674
  }
290
- config.persistCredsIfChanged(cfg, mgr.current);
291
675
 
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) => {
676
+ const blocked = (relPath) =>
677
+ failedFolders.some((f) => f.folderPath && relPath.startsWith(`${f.folderPath}/`));
678
+
679
+ // 8b) Subidas. El costo dominante no es el trabajo del servidor sino la
680
+ // latencia de cada round-trip, así que van agrupadas: el endpoint de lote
681
+ // escribe hasta 100 archivos por request y crea las carpetas que falten.
682
+ // El contenido se lee lote por lote, no todo de una: un repo grande no
683
+ // tiene por qué entrar entero en memoria.
684
+ const BATCH_MAX_FILES = COMMIT_BATCH; // 100: mismo techo que el commit
685
+ const BATCH_MAX_BYTES = 4 * 1024 * 1024; // el server corta en 5 MB; margen
686
+
687
+ const toUpload = [
688
+ ...plan.added.map((u) => ({ u, isNew: true })),
689
+ ...plan.modified.map((u) => ({ u, isNew: false })),
690
+ ];
691
+
692
+ // Empaquetado por cantidad Y por tamaño. Un archivo más grande que el
693
+ // presupuesto termina solo en su lote, y ahí se sube por el endpoint
694
+ // individual.
695
+ //
696
+ // Los lotes son HOMOGÉNEOS: o todo texto, o todo binario. Los dos grupos se
697
+ // escriben por caminos distintos —lote JSON contra URL firmada— y un lote
698
+ // mezclado obligaría a partirlo en el momento de escribir, justo donde después
699
+ // hay que volver a juntar los cambios para un solo commit. Separarlos acá
700
+ // deja esa parte sin ramas.
701
+ //
702
+ // El presupuesto de bytes no se le aplica a los binarios: su contenido nunca
703
+ // entra en el payload de un request, así que un lote de diez PDF de 30 MB son
704
+ // diez subidas directas y ningún JSON grande.
705
+ const batches = [];
706
+ for (const group of [toUpload.filter((e) => !e.u.binary), toUpload.filter((e) => e.u.binary)]) {
707
+ let current = [];
708
+ let currentBytes = 0;
709
+ for (const e of group) {
710
+ const bytes = e.u.binary ? 0 : typeof e.u.size === 'number' ? e.u.size : 0;
711
+ const full =
712
+ current.length >= BATCH_MAX_FILES || currentBytes + bytes > BATCH_MAX_BYTES;
713
+ if (current.length > 0 && full) {
714
+ batches.push(current);
715
+ current = [];
716
+ currentBytes = 0;
717
+ }
718
+ current.push(e);
719
+ currentBytes += bytes;
720
+ }
721
+ if (current.length > 0) batches.push(current);
722
+ }
723
+
724
+ const upProg = ui.progress(toUpload.length, 'subiendo');
725
+ let upDone = 0;
726
+ const tickUp = (n = 1) => {
727
+ upDone += n;
728
+ upProg.update(upDone);
729
+ };
730
+
731
+ const uploaded = []; // { relPath, size, mtimeMs, hash } para el índice
732
+ let batchSupported = true; // se descubre en el primer lote
733
+
734
+ /** Escribe un lote entero en un request. Devuelve un resultado por archivo. */
735
+ async function writeViaBatch(entries) {
736
+ const payload = entries.map((e) => ({
737
+ path: e.u.relPath,
738
+ content: e.content,
739
+ mimeType: e.u.mimeType,
740
+ }));
297
741
  const creds = await mgr.valid();
298
- const created = await client.createFile(projectId, u.relPath, u.content, u.mimeType, creds);
742
+ const { results } = await client.writeBatch(projectId, payload, creds);
743
+
744
+ return entries.map((e, i) => {
745
+ const r = results[i];
746
+ if (!r || !r.ok) {
747
+ return { ok: false, reason: (r && r.error) || 'el servidor no devolvió resultado' };
748
+ }
749
+ const f = r.file || {};
750
+ const action = r.action === 'created' ? 'added' : 'modified';
751
+ const change = { file_id: f.id, action, new_storage_path: f.storage_path };
752
+ if (action === 'modified') {
753
+ // El backend devuelve el storage_path que acaba de reemplazar; sólo si
754
+ // faltara caemos a lo que sabíamos del árbol remoto.
755
+ change.previous_storage_path =
756
+ f.previous_storage_path || (e.u.remote && e.u.remote.storage_path) || f.storage_path;
757
+ // El hash del contenido que estamos reemplazando lo sabe únicamente el
758
+ // cliente: cuando el commit llega al servidor, `files.content_hash` ya
759
+ // fue pisado con el contenido nuevo. Puede ser null en archivos
760
+ // anteriores a la migración 010.
761
+ if (e.u.remote && e.u.remote.content_hash) {
762
+ change.previous_content_hash = e.u.remote.content_hash;
763
+ }
764
+ }
765
+ return { ok: true, change, symbol: action === 'added' ? '+' : '~' };
766
+ });
767
+ }
768
+
769
+ /**
770
+ * Camino de los binarios: URL firmada, un archivo a la vez y en paralelo.
771
+ *
772
+ * No se agrupan en un request como el texto porque no hay nada que agrupar:
773
+ * cada byte va directo a Storage. Lo que sí se comparte es el control de
774
+ * ritmo, que es el que evita que veinte subidas de 30 MB salgan juntas.
775
+ *
776
+ * El endpoint crea las carpetas intermedias por su cuenta, igual que el lote,
777
+ * así que acá no hace falta `ensureFolders()`.
778
+ */
779
+ async function writeViaDirect(entries) {
780
+ const results = await sync.runPool(entries, throttle, async (e) => {
781
+ const creds = await mgr.valid();
782
+ const { result } = await client.uploadBinary(
783
+ projectId,
784
+ {
785
+ path: e.u.relPath,
786
+ buffer: e.content,
787
+ contentHash: pushindex.hashContent(e.content),
788
+ mimeType: e.u.mimeType,
789
+ },
790
+ creds
791
+ );
792
+
793
+ const f = (result && result.file) || {};
794
+ const action = result && result.action === 'created' ? 'added' : 'modified';
795
+ const change = { file_id: f.id, action, new_storage_path: f.storage_path };
796
+
797
+ if (action === 'modified') {
798
+ change.previous_storage_path =
799
+ f.previous_storage_path || (e.u.remote && e.u.remote.storage_path) || f.storage_path;
800
+ // El hash de lo que estamos reemplazando: el servidor lo lee antes de
801
+ // pisar la fila, que es el único momento en que existe. Si no vino, lo
802
+ // sabemos por el árbol remoto.
803
+ const previous =
804
+ f.previous_content_hash || (e.u.remote && e.u.remote.content_hash) || null;
805
+ if (previous) change.previous_content_hash = previous;
806
+ }
807
+
808
+ return { ok: true, change, symbol: action === 'added' ? '+' : '~' };
809
+ });
810
+
811
+ return results.map((r) => (r && r.__error ? { ok: false, reason: r.__error } : r));
812
+ }
813
+
814
+ /** Camino archivo-por-archivo: server sin /batch, o archivo demasiado grande. */
815
+ async function writeViaSingle(entries) {
816
+ await ensureFolders();
299
817
 
300
- if (created.created && created.file) {
818
+ const outcomes = new Array(entries.length);
819
+ const queue = [];
820
+ entries.forEach((e, i) => {
821
+ if (e.isNew && blocked(e.u.relPath)) {
822
+ outcomes[i] = { ok: false, reason: 'no se pudo crear su carpeta' };
823
+ } else {
824
+ queue.push({ e, i });
825
+ }
826
+ });
827
+
828
+ const results = await sync.runPool(queue, throttle, async ({ e }) => {
829
+ const u = e.u;
830
+ if (e.isNew) {
831
+ const creds = await mgr.valid();
832
+ const created = await client.createFile(projectId, u.relPath, e.content, u.mimeType, creds);
833
+ if (created.created && created.file) {
834
+ return {
835
+ ok: true,
836
+ symbol: '+',
837
+ change: {
838
+ file_id: created.file.id,
839
+ action: 'added',
840
+ new_storage_path: created.file.storage_path,
841
+ },
842
+ };
843
+ }
844
+ // 409: apareció entremedio; caemos a update.
845
+ const writeCreds = await mgr.valid();
846
+ const upd = await client.writeFile(projectId, u.relPath, e.content, u.mimeType, writeCreds);
847
+ return {
848
+ ok: true,
849
+ symbol: '~',
850
+ change: {
851
+ file_id: upd.file.id,
852
+ action: 'modified',
853
+ previous_storage_path:
854
+ (created.file && created.file.storage_path) || upd.file.storage_path,
855
+ new_storage_path: upd.file.storage_path,
856
+ },
857
+ };
858
+ }
859
+
860
+ const creds = await mgr.valid();
861
+ const upd = await client.writeFile(projectId, u.relPath, e.content, u.mimeType, creds);
301
862
  return {
302
- u,
303
- symbol: '+',
863
+ ok: true,
864
+ symbol: '~',
304
865
  change: {
305
- file_id: created.file.id,
306
- action: 'added',
307
- new_storage_path: created.file.storage_path,
866
+ file_id: upd.file.id,
867
+ action: 'modified',
868
+ previous_storage_path: u.remote ? u.remote.storage_path : upd.file.storage_path,
869
+ new_storage_path: upd.file.storage_path,
870
+ // Ver la nota en writeViaBatch: el hash previo sólo existe acá.
871
+ ...(u.remote && u.remote.content_hash
872
+ ? { previous_content_hash: u.remote.content_hash }
873
+ : {}),
308
874
  },
309
875
  };
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
- });
876
+ });
325
877
 
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;
878
+ // `runPool` devuelve los resultados alineados con la entrada, así que el
879
+ // índice de `queue` alcanza para volver a la posición original.
880
+ for (let k = 0; k < results.length; k += 1) {
881
+ const r = results[k];
882
+ outcomes[queue[k].i] = r && r.__error ? { ok: false, reason: r.__error } : r;
331
883
  }
332
- changes.push(r.change);
333
- ui.success(`${ui.c[r.symbol === '+' ? 'green' : 'yellow'](r.symbol)} ${r.u.relPath}`);
884
+ return outcomes;
334
885
  }
335
- config.persistCredsIfChanged(cfg, mgr.current);
336
886
 
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
- });
887
+ for (let bi = 0; bi < batches.length; bi += 1) {
888
+ const entries = batches[bi];
350
889
 
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;
890
+ // Un fallo de lectura (dejó de ser texto, permisos) saca a ese archivo del
891
+ // lote pero no arrastra a los demás.
892
+ const loaded = [];
893
+ for (const e of entries) {
894
+ try {
895
+ e.content = await loadContent(e.u);
896
+ loaded.push(e);
897
+ } catch (err) {
898
+ failedWrites.push({ relPath: e.u.relPath, reason: err.message });
899
+ logError(upProg, `${e.u.relPath} — ${err.message}`);
900
+ tickUp();
901
+ }
356
902
  }
357
- changes.push(r.change);
358
- ui.success(`${ui.c.yellow('~')} ${r.u.relPath}`);
359
- }
360
- config.persistCredsIfChanged(cfg, mgr.current);
903
+ if (loaded.length === 0) continue;
361
904
 
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);
905
+ // El lote se armó con lo que dijo el preflight, que sólo miró los primeros
906
+ // 8 KB. `loadContent` acaba de leer el archivo entero y pudo reclasificarlo,
907
+ // así que la partición se rehace ACÁ, sobre el contenido de verdad. Mandar
908
+ // un Buffer por el lote JSON lo convertiría en `{"type":"Buffer",...}`, que
909
+ // es la corrupción silenciosa que toda esta etapa existe para evitar.
910
+ const binaries = loaded.filter((e) => e.u.binary);
911
+ const texts = loaded.filter((e) => !e.u.binary);
912
+
913
+ const outcomeByEntry = new Map();
914
+
915
+ if (binaries.length > 0) {
916
+ // Un fallo acá es por archivo, no del lote: `writeViaDirect` devuelve un
917
+ // resultado por entrada y los errores ya vienen adentro.
918
+ const res = await writeViaDirect(binaries);
919
+ binaries.forEach((e, i) => outcomeByEntry.set(e, res[i]));
920
+ }
921
+
922
+ if (texts.length > 0) {
923
+ const oversize =
924
+ texts.length === 1 && Buffer.byteLength(texts[0].content, 'utf8') > BATCH_MAX_BYTES;
925
+ let res = null;
926
+
927
+ if (batchSupported && !oversize) {
928
+ try {
929
+ res = await writeViaBatch(texts);
930
+ } catch (err) {
931
+ if (err.notSupported) {
932
+ batchSupported = false;
933
+ upProg.clear();
934
+ ui.warn('El servidor no acepta escritura por lotes; se sube archivo por archivo.');
935
+ } else {
936
+ // Falla del request entero (red, 5xx, permisos): ningún archivo de
937
+ // texto del lote se escribió. Los binarios ya se resolvieron aparte
938
+ // y no los arrastra.
939
+ res = texts.map(() => ({ ok: false, reason: err.message }));
940
+ }
941
+ }
942
+ }
943
+
944
+ if (!res) res = await writeViaSingle(texts);
945
+ texts.forEach((e, i) => outcomeByEntry.set(e, res[i]));
946
+ }
947
+
948
+ const outcomes = loaded.map((e) => outcomeByEntry.get(e));
949
+ config.persistCredsIfChanged(cfg, mgr.current);
950
+
951
+ const batchChanges = [];
952
+ for (let i = 0; i < loaded.length; i += 1) {
953
+ const e = loaded[i];
954
+ const o = outcomes[i];
955
+ if (!o || !o.ok) {
956
+ const reason = (o && o.reason) || 'error desconocido';
957
+ failedWrites.push({ relPath: e.u.relPath, reason });
958
+ logError(upProg, `${e.u.relPath} — ${reason}`);
959
+ continue;
960
+ }
961
+ batchChanges.push(o.change);
962
+ uploaded.push({ ...e.u, hash: pushindex.hashContent(e.content) });
963
+ logFile(upProg, `${ui.c[o.symbol === '+' ? 'green' : 'yellow'](o.symbol)} ${e.u.relPath}`);
964
+ }
965
+ tickUp(loaded.length);
966
+
967
+ // 9) Commit del lote, apenas termina de escribirse. Juntar todos los cambios
968
+ // para un commit final ahorraba requests, pero dejaba una ventana larga
969
+ // en la que los archivos ya estaban escritos y ningún commit los
970
+ // mencionaba; si el push moría ahí, el historial quedaba mintiendo.
971
+ if (batchChanges.length > 0) {
972
+ const partNo = bi + 1;
973
+ const msg = buildMessage(
974
+ message,
975
+ batchChanges.length,
976
+ toUpload.length,
977
+ partNo,
978
+ batches.length,
979
+ card
980
+ );
371
981
  try {
372
982
  const creds = await mgr.valid();
373
- await client.createCommit(projectId, msg, batch, creds);
374
- committed += batch.length;
983
+ await client.createCommit(projectId, msg, batchChanges, creds);
984
+ committed += batchChanges.length;
985
+ commitCount += 1;
375
986
  config.persistCredsIfChanged(cfg, mgr.current);
376
987
  } catch (e) {
377
- ui.error(`Fallo al crear el commit (lote ${partNo}/${nBatches}): ${e.message}`);
988
+ ui.error(`Fallo al crear el commit (lote ${partNo}/${batches.length}): ${e.message}`);
378
989
  failedWrites.push({ relPath: `<commit lote ${partNo}>`, reason: e.message });
379
990
  }
380
991
  }
992
+
993
+ // El contenido ya se hasheó y se subió: soltarlo mantiene el pico de memoria
994
+ // atado al tamaño del lote y no al del repo.
995
+ for (const e of loaded) {
996
+ e.content = null;
997
+ e.u.content = null;
998
+ }
381
999
  }
1000
+ upProg.clear();
382
1001
 
383
1002
  config.persistCredsIfChanged(cfg, mgr.current);
384
1003
 
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) {
1004
+ // 10) Índice local: lo que subimos bien y lo que ya estaba igual. El próximo
1005
+ // push arranca sabiendo qué no hace falta ni leer.
1006
+ if (useIndex) {
1007
+ for (const u of uploaded) {
1008
+ pushindex.record(index, u, pushedAt);
1009
+ }
1010
+ saveIndex(dir, index, plan, uploads, pushedAt);
1011
+ }
1012
+
1013
+ // 11) Anotar la sincronización: `status` la usa después para saber qué del
1014
+ // remoto es posterior a lo último que subimos. Sólo si TODO salió bien:
1015
+ // con archivos fallados, marcar "sincronizado" haría que status mienta.
1016
+ if (failedWrites.length === 0 && plan.failed.length === 0) {
388
1017
  remote.touchSync(dir, { projectId, baseURL });
389
1018
  }
390
1019
 
391
- // 11) Resumen final.
1020
+ // 12) Resumen final.
392
1021
  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
- );
1022
+ if (failedWrites.length === 0 && plan.failed.length === 0) {
1023
+ ui.success(`Push completo: ${committed} archivo(s) en ${commitCount} commit(s).`);
398
1024
  return 0;
399
1025
  }
400
- ui.warn(`Push con problemas: ${failedWrites.length} archivo(s)/commit(s) fallaron.`);
1026
+ for (const f of plan.failed) ui.error(`${f.relPath} — ${f.reason}`);
1027
+ ui.warn(
1028
+ `Push con problemas: ${failedWrites.length + plan.failed.length} archivo(s)/commit(s) fallaron.`
1029
+ );
401
1030
  return 1;
402
1031
  }
403
1032
 
1033
+ /**
1034
+ * Lee el contenido completo de un archivo, cacheado en la propia entrada.
1035
+ *
1036
+ * Devuelve un `string` para texto y un `Buffer` para binarios. Esa diferencia
1037
+ * es la que decide por qué camino sube el archivo, así que se re-clasifica acá y
1038
+ * no se confía en lo que dijo el preflight: entre una cosa y la otra el archivo
1039
+ * pudo cambiar, y el preflight sólo miró los primeros 8 KB.
1040
+ *
1041
+ * `u.binary` y `u.mimeType` se corrigen con lo que digan los bytes completos.
1042
+ * Un archivo que el preflight leyó como texto y resultó binario tiene que
1043
+ * cambiar de camino, no subirse mal por el que ya tenía asignado.
1044
+ */
1045
+ async function loadContent(u) {
1046
+ if (u.content != null) return u.content;
1047
+
1048
+ const buf = await fs.promises.readFile(u.absPath);
1049
+ const cls = classifyContent(path.basename(u.relPath), buf);
1050
+
1051
+ if (cls.isBinary) {
1052
+ u.binary = true;
1053
+ u.mimeType = cls.mimeType;
1054
+ u.content = cls.buffer;
1055
+ return u.content;
1056
+ }
1057
+
1058
+ if (!cls.isText) throw new Error(cls.reason);
1059
+
1060
+ u.binary = false;
1061
+ u.content = cls.content;
1062
+ return u.content;
1063
+ }
1064
+
1065
+ /**
1066
+ * Registra en el índice los archivos que ya estaban idénticos (para no volver a
1067
+ * compararlos), saca los que ya no existen y lo escribe.
1068
+ */
1069
+ function saveIndex(dir, index, plan, uploads, pushedAt = Date.now()) {
1070
+ for (const u of plan.unchanged) {
1071
+ if (u.content != null) {
1072
+ pushindex.record(index, { ...u, hash: pushindex.hashContent(u.content) }, pushedAt);
1073
+ }
1074
+ }
1075
+ pushindex.prune(index, new Set(uploads.map((u) => u.relPath)));
1076
+ pushindex.write(dir, index);
1077
+ }
1078
+
404
1079
  /**
405
1080
  * Arma el mensaje del commit. Si vino `--card`, se agrega como trailer
406
1081
  * `[card: <id>]` al final: queda visible en el historial y es fácil de grepear