shilo-cli 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -8
- package/bin/shilo.js +21 -2
- package/package.json +1 -1
- package/src/ShiloClient.js +265 -4
- package/src/commands/pull.js +56 -12
- package/src/commands/push.js +695 -338
- package/src/pushindex.js +6 -1
- package/src/remote.js +15 -3
- package/src/textguard.js +70 -19
package/src/commands/push.js
CHANGED
|
@@ -13,6 +13,7 @@ const { walk } = require('../walk');
|
|
|
13
13
|
const {
|
|
14
14
|
classifyContent,
|
|
15
15
|
looksBinary,
|
|
16
|
+
binaryMimeType,
|
|
16
17
|
extensionOf,
|
|
17
18
|
BINARY_EXTENSIONS,
|
|
18
19
|
MAX_FILE_BYTES,
|
|
@@ -20,7 +21,14 @@ const {
|
|
|
20
21
|
const { ShiloClient } = require('../ShiloClient');
|
|
21
22
|
|
|
22
23
|
const DEFAULT_BASE_URL = 'https://www.shilo.city';
|
|
23
|
-
const COMMIT_BATCH = 100; // máximo de archivos por commit (límite del backend)
|
|
24
|
+
const COMMIT_BATCH = 100; // máximo de archivos por commit (límite del backend, camino anterior)
|
|
25
|
+
|
|
26
|
+
// Techos de una propuesta (los del endpoint /api/projects/proposals). Un texto
|
|
27
|
+
// más grande que PROPOSAL_INLINE_MAX no va inline: se inventaría por URL
|
|
28
|
+
// firmada, como un binario, y entra por su oid.
|
|
29
|
+
const PROPOSAL_MAX_CHANGES = 500;
|
|
30
|
+
const PROPOSAL_INLINE_MAX = 1024 * 1024;
|
|
31
|
+
const PROPOSAL_MAX_BYTES = 4 * 1024 * 1024; // el server corta en 5 MB; margen
|
|
24
32
|
|
|
25
33
|
// Cuánto se lee de cada archivo en el preflight: alcanza para decidir binario
|
|
26
34
|
// (looksBinary mira 8000 bytes) y para escanear secretos, sin cargar el repo
|
|
@@ -54,7 +62,9 @@ const SKIP_LABELS = {
|
|
|
54
62
|
symlink: 'symlinks (no soportados)',
|
|
55
63
|
name: 'nombre no permitido por Shilo',
|
|
56
64
|
depth: 'demasiado anidados',
|
|
57
|
-
|
|
65
|
+
// Los binarios ya NO se saltean: desde la Etapa 2 suben por URL firmada. Este
|
|
66
|
+
// motivo quedó sólo para lo que supera el techo de tamaño.
|
|
67
|
+
size: `mayores a ${MAX_FILE_BYTES / (1024 * 1024)} MB`,
|
|
58
68
|
};
|
|
59
69
|
|
|
60
70
|
/** Lee como mucho `n` bytes del principio del archivo. */
|
|
@@ -78,6 +88,12 @@ function readHead(absPath, n) {
|
|
|
78
88
|
*
|
|
79
89
|
* Sólo se leen los primeros KB de cada archivo; el contenido completo se lee
|
|
80
90
|
* recién cuando se sabe que ese archivo se sube.
|
|
91
|
+
*
|
|
92
|
+
* Desde la Etapa 2 esta fase ya no descarta binarios: los MARCA. Un archivo con
|
|
93
|
+
* `binary: true` sube por URL firmada (`client.uploadBinary`) en vez de por el
|
|
94
|
+
* lote JSON, porque el lote hashea el contenido como utf8 y ahí un binario se
|
|
95
|
+
* corrompe antes de que nadie pueda notarlo. Lo único que sigue siendo motivo de
|
|
96
|
+
* salteo es superar el techo de tamaño.
|
|
81
97
|
*/
|
|
82
98
|
function preflight(dir) {
|
|
83
99
|
const { files, skipped, ignored, gitDirs } = walk(dir);
|
|
@@ -89,41 +105,43 @@ function preflight(dir) {
|
|
|
89
105
|
for (const f of files) {
|
|
90
106
|
const name = path.basename(f.relPath);
|
|
91
107
|
|
|
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
108
|
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
109
|
skips.push({
|
|
112
110
|
relPath: f.relPath,
|
|
113
|
-
reason:
|
|
114
|
-
code: '
|
|
111
|
+
reason: `supera ${MAX_FILE_BYTES / (1024 * 1024)} MB (${f.size} bytes)`,
|
|
112
|
+
code: 'size',
|
|
115
113
|
});
|
|
116
114
|
continue;
|
|
117
115
|
}
|
|
118
116
|
|
|
119
|
-
|
|
117
|
+
const ext = extensionOf(name);
|
|
118
|
+
let binary = Boolean(ext && BINARY_EXTENSIONS.has(ext));
|
|
119
|
+
let head = null;
|
|
120
|
+
|
|
121
|
+
// Un archivo cuya extensión ya dice que es binario no hace falta leerlo para
|
|
122
|
+
// saberlo, y leerlo sólo serviría para escanearle secretos — que en un JPEG
|
|
123
|
+
// son ruido.
|
|
124
|
+
if (!binary) {
|
|
125
|
+
try {
|
|
126
|
+
head = readHead(f.absPath, HEAD_BYTES);
|
|
127
|
+
} catch (e) {
|
|
128
|
+
skips.push({ relPath: f.relPath, reason: `no se pudo leer: ${e.message}`, code: 'unreadable' });
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
binary = looksBinary(head);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (!binary) {
|
|
135
|
+
findings.push(...secrets.inspect(f.relPath, head.toString('utf8')));
|
|
136
|
+
}
|
|
120
137
|
|
|
121
138
|
uploads.push({
|
|
122
139
|
relPath: f.relPath,
|
|
123
140
|
absPath: f.absPath,
|
|
124
141
|
size: f.size,
|
|
125
142
|
mtimeMs: f.mtimeMs,
|
|
126
|
-
|
|
143
|
+
binary,
|
|
144
|
+
mimeType: binary ? binaryMimeType(name) : guessMime(f.relPath),
|
|
127
145
|
content: null,
|
|
128
146
|
});
|
|
129
147
|
}
|
|
@@ -226,9 +244,11 @@ function neededFolders(relPaths) {
|
|
|
226
244
|
* shilo push [carpeta] [--project <uuid>] [-m <msg>] [--dry-run] [--yes]
|
|
227
245
|
* [--allow-secrets] [--concurrency <n>] [--no-index] [--verbose]
|
|
228
246
|
*
|
|
229
|
-
* Replica una carpeta local a un repo de Shilo: crea archivos nuevos
|
|
230
|
-
* los que cambiaron
|
|
231
|
-
*
|
|
247
|
+
* Replica una carpeta local a un repo de Shilo: crea archivos nuevos y actualiza
|
|
248
|
+
* los que cambiaron. Desde la Etapa 3 lo hace como PROPUESTA: el servidor la
|
|
249
|
+
* aplica si combina limpio con la versión actual, y si otra persona cambió
|
|
250
|
+
* alguno de los mismos archivos la deja abierta sin pisar nada. Con un servidor
|
|
251
|
+
* anterior cae al camino viejo (escribir + commit), que sí puede pisar.
|
|
232
252
|
*/
|
|
233
253
|
async function push(args) {
|
|
234
254
|
const dir = path.resolve(args._[0] || '.');
|
|
@@ -301,7 +321,7 @@ async function push(args) {
|
|
|
301
321
|
}
|
|
302
322
|
|
|
303
323
|
if (uploads.length === 0) {
|
|
304
|
-
ui.info('No hay archivos
|
|
324
|
+
ui.info('No hay archivos para subir.');
|
|
305
325
|
ui.info(ui.c.dim(`(${ignored} ignorados, ${skipped.length} salteados)`));
|
|
306
326
|
return 0;
|
|
307
327
|
}
|
|
@@ -323,7 +343,8 @@ async function push(args) {
|
|
|
323
343
|
|
|
324
344
|
ui.info(
|
|
325
345
|
`Proyecto ${ui.c.bold(projectId)} en ${ui.c.cyan(baseURL)} — ` +
|
|
326
|
-
`${uploads.length} archivo(s)
|
|
346
|
+
`${uploads.length} archivo(s) (${uploads.filter((u) => u.binary).length} binario(s)), ` +
|
|
347
|
+
`${ignored} ignorado(s), ` +
|
|
327
348
|
`${skipped.length} salteado(s).`
|
|
328
349
|
);
|
|
329
350
|
|
|
@@ -457,6 +478,19 @@ async function push(args) {
|
|
|
457
478
|
}
|
|
458
479
|
|
|
459
480
|
const content = await loadContent(u);
|
|
481
|
+
|
|
482
|
+
// Un binario no se compara bajando el contenido: el GET devuelve una URL
|
|
483
|
+
// firmada, no bytes, justamente para no arrastrar el archivo por el
|
|
484
|
+
// payload de la función. Sin `content_hash` remoto no queda con qué
|
|
485
|
+
// comparar, así que se asume modificado. El servidor deduplica: si el
|
|
486
|
+
// contenido es el mismo, el blob ya existe y no se transfiere un byte.
|
|
487
|
+
if (u.binary) {
|
|
488
|
+
const creds = await mgr.valid();
|
|
489
|
+
const meta = await client.getFileMeta(projectId, u.relPath, creds);
|
|
490
|
+
if (!meta.exists) return { kind: 'added', u: { ...u, content } };
|
|
491
|
+
return { kind: 'modified', u: { ...u, content, remote: meta.file } };
|
|
492
|
+
}
|
|
493
|
+
|
|
460
494
|
const creds = await mgr.valid();
|
|
461
495
|
const read = await client.readFile(projectId, u.relPath, creds);
|
|
462
496
|
if (read.file && read.file.content === content) {
|
|
@@ -479,30 +513,9 @@ async function push(args) {
|
|
|
479
513
|
|
|
480
514
|
config.persistCredsIfChanged(cfg, mgr.current);
|
|
481
515
|
|
|
482
|
-
//
|
|
483
|
-
//
|
|
484
|
-
// compañero". Best-effort: si el historial no se puede leer, seguimos.
|
|
485
|
-
let attribution = { index: new Map() };
|
|
516
|
+
// Con token de acceso personal no sabemos el id del usuario; sólo importa
|
|
517
|
+
// para reconocer propuestas propias que este push reemplaza.
|
|
486
518
|
const myId = cfg.credentials && cfg.credentials.user ? cfg.credentials.user.id : null;
|
|
487
|
-
if (plan.modified.length > 0) {
|
|
488
|
-
attribution = await sync.buildAuthorIndex(client, projectId, mgr, { commits: 50 });
|
|
489
|
-
config.persistCredsIfChanged(cfg, mgr.current);
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
/** Etiqueta " — última edición de X, fecha" para un archivo del plan. */
|
|
493
|
-
const authorNote = (u) => {
|
|
494
|
-
const meta = u.remote && attribution.index.get(u.remote.id);
|
|
495
|
-
if (!meta) return '';
|
|
496
|
-
const mine = myId && meta.authorId && meta.authorId === myId;
|
|
497
|
-
const who = mine ? ui.c.dim('vos') : ui.c.bold(meta.author);
|
|
498
|
-
return ui.c.dim(' — última edición de ') + who + ui.c.dim(`, ${sync.formatDate(meta.date)}`);
|
|
499
|
-
};
|
|
500
|
-
|
|
501
|
-
/** ¿El último que tocó este archivo fue otra persona? */
|
|
502
|
-
const touchedByOthers = plan.modified.filter((u) => {
|
|
503
|
-
const meta = u.remote && attribution.index.get(u.remote.id);
|
|
504
|
-
return meta && meta.authorId && myId && meta.authorId !== myId;
|
|
505
|
-
});
|
|
506
519
|
|
|
507
520
|
// 6) Reporte del plan.
|
|
508
521
|
ui.info('');
|
|
@@ -527,38 +540,16 @@ async function push(args) {
|
|
|
527
540
|
|
|
528
541
|
if (dryRun) {
|
|
529
542
|
for (const u of plan.added) ui.info(` ${ui.c.green('+')} ${u.relPath}`);
|
|
530
|
-
for (const u of plan.modified) ui.info(` ${ui.c.yellow('~')} ${u.relPath}
|
|
543
|
+
for (const u of plan.modified) ui.info(` ${ui.c.yellow('~')} ${u.relPath}`);
|
|
531
544
|
for (const f of plan.failed) ui.info(` ${ui.c.red('!')} ${f.relPath} — ${f.reason}`);
|
|
532
545
|
ui.info('');
|
|
533
|
-
if (!myId && plan.modified.length > 0 && attribution.index.size > 0) {
|
|
534
|
-
ui.info(
|
|
535
|
-
ui.c.dim(
|
|
536
|
-
'(con token de acceso personal no sabemos tu id de usuario: ' +
|
|
537
|
-
'el autor se muestra, pero no se marca cuál sos vos)'
|
|
538
|
-
)
|
|
539
|
-
);
|
|
540
|
-
ui.info('');
|
|
541
|
-
}
|
|
542
|
-
if (touchedByOthers.length > 0) {
|
|
543
|
-
ui.warn(
|
|
544
|
-
`${touchedByOthers.length} de esos archivos los editó otra persona: ` +
|
|
545
|
-
`este push pisaría su versión.`
|
|
546
|
-
);
|
|
547
|
-
ui.info(ui.c.dim('Revisalos con "shilo diff <archivo>" o traelos con "shilo pull".'));
|
|
548
|
-
ui.info('');
|
|
549
|
-
}
|
|
550
546
|
ui.info(ui.c.dim('(--dry-run: no se subió nada)'));
|
|
551
547
|
return plan.failed.length ? 1 : 0;
|
|
552
548
|
}
|
|
553
549
|
|
|
554
|
-
// 7) Confirmar (salvo --yes).
|
|
555
|
-
//
|
|
556
|
-
|
|
557
|
-
ui.warn(`Vas a pisar ${touchedByOthers.length} archivo(s) editado(s) por otra persona:`);
|
|
558
|
-
for (const u of touchedByOthers) ui.info(` ${ui.c.red('!')} ${u.relPath}${authorNote(u)}`);
|
|
559
|
-
ui.info('');
|
|
560
|
-
}
|
|
561
|
-
|
|
550
|
+
// 7) Confirmar (salvo --yes). Ya no hace falta advertir "vas a pisar trabajo
|
|
551
|
+
// ajeno": desde la Etapa 3 el servidor no deja pisar nada. Si alguien más
|
|
552
|
+
// cambió el mismo archivo, la propuesta queda abierta y se informa abajo.
|
|
562
553
|
if (!args.yes) {
|
|
563
554
|
const ok = await prompt.confirm(`¿Subir ${toWrite} archivo(s) al proyecto?`, true);
|
|
564
555
|
if (!ok) {
|
|
@@ -567,13 +558,7 @@ async function push(args) {
|
|
|
567
558
|
}
|
|
568
559
|
}
|
|
569
560
|
|
|
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.
|
|
573
|
-
const failedWrites = [];
|
|
574
561
|
const pushedAt = Date.now();
|
|
575
|
-
let committed = 0;
|
|
576
|
-
let commitCount = 0;
|
|
577
562
|
|
|
578
563
|
/** Log por archivo: en TTY lo tapa la barra de progreso, así que va sólo con --verbose. */
|
|
579
564
|
const logFile = (prog, msg) => {
|
|
@@ -589,311 +574,603 @@ async function push(args) {
|
|
|
589
574
|
ui.error(msg);
|
|
590
575
|
};
|
|
591
576
|
|
|
592
|
-
//
|
|
593
|
-
//
|
|
594
|
-
//
|
|
595
|
-
//
|
|
596
|
-
//
|
|
577
|
+
// 8) Proponer. Todo lo que cambió va al servidor como UNA propuesta (o varias
|
|
578
|
+
// si no entra en una: ver PROPOSAL_MAX_*) que parte de la versión que esta
|
|
579
|
+
// carpeta vio por última vez (`.shilo.json` → `base`). El servidor la
|
|
580
|
+
// aplica solo si combina limpio con la versión actual; si otra persona
|
|
581
|
+
// cambió alguno de los mismos archivos, la deja abierta y NO pisa nada.
|
|
597
582
|
//
|
|
598
|
-
//
|
|
599
|
-
//
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
async function
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
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
|
-
}
|
|
583
|
+
// El texto viaja inline; los binarios (y el texto que no entra en el JSON)
|
|
584
|
+
// se inventarian primero por URL firmada y entran por su oid.
|
|
585
|
+
//
|
|
586
|
+
// Devuelve null si el servidor no conoce las propuestas todavía: entonces
|
|
587
|
+
// se usa el camino anterior, que sí puede pisar.
|
|
588
|
+
async function pushViaProposals() {
|
|
589
|
+
let listed;
|
|
590
|
+
try {
|
|
591
|
+
listed = await client.listProposals(projectId, await mgr.valid(), { state: 'abierta' });
|
|
592
|
+
} catch (e) {
|
|
593
|
+
if (e.notSupported) return null;
|
|
594
|
+
throw e;
|
|
647
595
|
}
|
|
648
596
|
config.persistCredsIfChanged(cfg, mgr.current);
|
|
649
|
-
}
|
|
650
597
|
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
598
|
+
const knownBase = savedRemote && savedRemote.base ? savedRemote.base : null;
|
|
599
|
+
let base = knownBase || listed.main;
|
|
600
|
+
if (!knownBase && listed.main) {
|
|
601
|
+
ui.info(
|
|
602
|
+
ui.c.dim(
|
|
603
|
+
'Esta carpeta no tenía anotada de qué versión partió: se toma la actual. ' +
|
|
604
|
+
'Desde ahora el push sabrá si alguien cambió lo mismo que vos.'
|
|
605
|
+
)
|
|
606
|
+
);
|
|
607
|
+
}
|
|
661
608
|
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
609
|
+
// 8a) Propuestas propias que siguen abiertas: este push las reemplaza, porque
|
|
610
|
+
// la carpeta local es lo que la persona quiere que quede. Si se dejaran,
|
|
611
|
+
// bloquearían a la nueva por tocar los mismos archivos.
|
|
612
|
+
const mine = myId ? listed.proposals.filter((pr) => pr.author_id === myId) : [];
|
|
613
|
+
for (const pr of mine) {
|
|
614
|
+
try {
|
|
615
|
+
await client.resolveProposal(projectId, pr.id, 'discard', await mgr.valid());
|
|
616
|
+
ui.info(ui.c.dim(`Se descartó tu propuesta anterior "${pr.title || pr.id.slice(0, 8)}": este push la reemplaza.`));
|
|
617
|
+
} catch (e) {
|
|
618
|
+
ui.warn(`No se pudo descartar tu propuesta anterior ${pr.id.slice(0, 8)}: ${e.message}`);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
666
621
|
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
{
|
|
622
|
+
// 8b) Lotes: hasta PROPOSAL_MAX_CHANGES cambios y PROPOSAL_MAX_BYTES de
|
|
623
|
+
// texto inline por propuesta. Los binarios no suman bytes: van por oid.
|
|
624
|
+
const toSend = [...plan.added, ...plan.modified];
|
|
625
|
+
const batches = [];
|
|
672
626
|
let current = [];
|
|
673
627
|
let currentBytes = 0;
|
|
674
|
-
for (const
|
|
675
|
-
const bytes =
|
|
628
|
+
for (const u of toSend) {
|
|
629
|
+
const bytes = u.binary || u.size > PROPOSAL_INLINE_MAX ? 0 : u.size;
|
|
676
630
|
const full =
|
|
677
|
-
current.length >=
|
|
631
|
+
current.length >= PROPOSAL_MAX_CHANGES || currentBytes + bytes > PROPOSAL_MAX_BYTES;
|
|
678
632
|
if (current.length > 0 && full) {
|
|
679
633
|
batches.push(current);
|
|
680
634
|
current = [];
|
|
681
635
|
currentBytes = 0;
|
|
682
636
|
}
|
|
683
|
-
current.push(
|
|
637
|
+
current.push(u);
|
|
684
638
|
currentBytes += bytes;
|
|
685
639
|
}
|
|
686
640
|
if (current.length > 0) batches.push(current);
|
|
687
|
-
}
|
|
688
641
|
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
642
|
+
const failedWrites = [];
|
|
643
|
+
const uploaded = [];
|
|
644
|
+
const open = [];
|
|
645
|
+
let applied = 0;
|
|
646
|
+
let appliedCount = 0;
|
|
647
|
+
let stale = 0;
|
|
648
|
+
let main = listed.main;
|
|
695
649
|
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
650
|
+
const upProg = ui.progress(toSend.length, 'subiendo');
|
|
651
|
+
let upDone = 0;
|
|
652
|
+
|
|
653
|
+
for (let bi = 0; bi < batches.length; bi += 1) {
|
|
654
|
+
const entries = batches[bi];
|
|
655
|
+
|
|
656
|
+
// Contenido completo, con reclasificación (ver loadContent).
|
|
657
|
+
const loaded = [];
|
|
658
|
+
for (const u of entries) {
|
|
659
|
+
try {
|
|
660
|
+
await loadContent(u);
|
|
661
|
+
loaded.push(u);
|
|
662
|
+
} catch (err) {
|
|
663
|
+
failedWrites.push({ relPath: u.relPath, reason: err.message });
|
|
664
|
+
logError(upProg, `${u.relPath} — ${err.message}`);
|
|
665
|
+
upDone += 1;
|
|
666
|
+
upProg.update(upDone);
|
|
667
|
+
}
|
|
713
668
|
}
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
669
|
+
if (loaded.length === 0) continue;
|
|
670
|
+
|
|
671
|
+
// Lo que no puede ir inline se inventaría antes, en paralelo y con el
|
|
672
|
+
// mismo control de ritmo que cualquier subida.
|
|
673
|
+
const byOid = loaded.filter(
|
|
674
|
+
(u) => u.binary || Buffer.byteLength(u.content, 'utf8') > PROPOSAL_INLINE_MAX
|
|
675
|
+
);
|
|
676
|
+
const registered = new Map();
|
|
677
|
+
if (byOid.length > 0) {
|
|
678
|
+
const results = await sync.runPool(byOid, throttle, async (u) => {
|
|
679
|
+
const buffer = Buffer.isBuffer(u.content) ? u.content : Buffer.from(u.content, 'utf8');
|
|
680
|
+
const creds = await mgr.valid();
|
|
681
|
+
const { oid } = await client.registerBlob(
|
|
682
|
+
projectId,
|
|
683
|
+
{ path: u.relPath, buffer, contentHash: pushindex.hashContent(buffer), mimeType: u.mimeType },
|
|
684
|
+
creds
|
|
685
|
+
);
|
|
686
|
+
return oid;
|
|
687
|
+
});
|
|
688
|
+
results.forEach((r, i) => {
|
|
689
|
+
if (r && r.__error) {
|
|
690
|
+
failedWrites.push({ relPath: byOid[i].relPath, reason: r.__error });
|
|
691
|
+
logError(upProg, `${byOid[i].relPath} — ${r.__error}`);
|
|
692
|
+
} else {
|
|
693
|
+
registered.set(byOid[i], r);
|
|
694
|
+
}
|
|
695
|
+
});
|
|
696
|
+
config.persistCredsIfChanged(cfg, mgr.current);
|
|
722
697
|
}
|
|
723
|
-
|
|
724
|
-
|
|
698
|
+
|
|
699
|
+
const sent = [];
|
|
700
|
+
const changes = [];
|
|
701
|
+
for (const u of loaded) {
|
|
702
|
+
if (byOid.includes(u)) {
|
|
703
|
+
if (!registered.has(u)) continue;
|
|
704
|
+
changes.push({ path: u.relPath, oid: registered.get(u) });
|
|
705
|
+
} else {
|
|
706
|
+
changes.push({ path: u.relPath, content: u.content, mime_type: u.mimeType });
|
|
707
|
+
}
|
|
708
|
+
sent.push(u);
|
|
709
|
+
}
|
|
710
|
+
upDone += loaded.length;
|
|
711
|
+
upProg.update(upDone);
|
|
712
|
+
if (changes.length === 0) continue;
|
|
713
|
+
|
|
714
|
+
const title = buildMessage(message, changes.length, toSend.length, bi + 1, batches.length, card);
|
|
715
|
+
let result;
|
|
716
|
+
try {
|
|
717
|
+
result = await client.propose(projectId, { base, title, summary: '', changes }, await mgr.valid());
|
|
718
|
+
config.persistCredsIfChanged(cfg, mgr.current);
|
|
719
|
+
} catch (e) {
|
|
720
|
+
// Una base que el servidor no conoce (proyecto recreado, historial
|
|
721
|
+
// podado) no es motivo para no guardar: se reintenta desde la actual.
|
|
722
|
+
if (e.status === 404 && base && base !== main && e.body && /punto de guardado/i.test(e.body.error || '')) {
|
|
723
|
+
ui.warn('El servidor no reconoce la versión de partida anotada: se parte de la actual.');
|
|
724
|
+
base = main;
|
|
725
|
+
bi -= 1;
|
|
726
|
+
for (const u of loaded) u.content = null;
|
|
727
|
+
continue;
|
|
728
|
+
}
|
|
729
|
+
for (const u of sent) failedWrites.push({ relPath: u.relPath, reason: e.message });
|
|
730
|
+
logError(upProg, `Lote ${bi + 1}/${batches.length} — ${e.message}`);
|
|
731
|
+
for (const u of loaded) u.content = null;
|
|
732
|
+
continue;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
if (result.main) main = result.main;
|
|
736
|
+
for (const u of sent) uploaded.push({ ...u, hash: pushindex.hashContent(u.content) });
|
|
737
|
+
|
|
738
|
+
if (result.empty) {
|
|
739
|
+
stale += sent.length;
|
|
740
|
+
} else if (result.applied) {
|
|
741
|
+
applied += sent.length;
|
|
742
|
+
appliedCount += 1;
|
|
743
|
+
for (const u of sent) {
|
|
744
|
+
logFile(upProg, `${u.remote ? ui.c.yellow('~') : ui.c.green('+')} ${u.relPath}`);
|
|
745
|
+
}
|
|
746
|
+
} else {
|
|
747
|
+
open.push({ ...result, paths: sent.map((u) => u.relPath) });
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
for (const u of loaded) u.content = null;
|
|
751
|
+
}
|
|
752
|
+
upProg.clear();
|
|
753
|
+
|
|
754
|
+
return { failedWrites, committed: applied, commitCount: appliedCount, uploaded, open, stale, main, base };
|
|
725
755
|
}
|
|
726
756
|
|
|
727
|
-
/**
|
|
728
|
-
|
|
729
|
-
|
|
757
|
+
/**
|
|
758
|
+
* El camino anterior a la Etapa 3: escribe archivo por archivo (o por lote) y
|
|
759
|
+
* después registra el commit. Puede pisar cambios ajenos, porque el servidor
|
|
760
|
+
* viejo no tiene con qué compararlos. Queda hasta que ningún servidor lo
|
|
761
|
+
* necesite.
|
|
762
|
+
*/
|
|
763
|
+
async function pushLegacy() {
|
|
764
|
+
const failedWrites = [];
|
|
765
|
+
let committed = 0;
|
|
766
|
+
let commitCount = 0;
|
|
767
|
+
|
|
768
|
+
// 8a) Carpetas faltantes, por nivel. El árbol remoto ya nos dice cuáles
|
|
769
|
+
// existen, así que no hace falta listar antes de crear cada una; y crear
|
|
770
|
+
// nivel por nivel (paralelo dentro del nivel, serie entre niveles)
|
|
771
|
+
// garantiza que el padre exista sin depender del 409 del backend, que no
|
|
772
|
+
// es confiable ante dos creaciones simultáneas de la misma carpeta.
|
|
773
|
+
//
|
|
774
|
+
// Se ejecuta a demanda: el endpoint de lote hace mkdir -p por su cuenta,
|
|
775
|
+
// así que estos requests sólo hacen falta en el camino archivo-por-archivo.
|
|
776
|
+
const failedFolders = [];
|
|
777
|
+
let foldersEnsured = false;
|
|
778
|
+
|
|
779
|
+
async function ensureFolders() {
|
|
780
|
+
if (foldersEnsured) return;
|
|
781
|
+
foldersEnsured = true;
|
|
782
|
+
if (plan.added.length === 0) return;
|
|
783
|
+
|
|
784
|
+
if (treeComplete) {
|
|
785
|
+
const missing = [...neededFolders(plan.added.map((u) => u.relPath))].filter(
|
|
786
|
+
(f) => !tree.folders.has(f)
|
|
787
|
+
);
|
|
788
|
+
const byDepth = new Map();
|
|
789
|
+
for (const f of missing) {
|
|
790
|
+
const d = f.split('/').length;
|
|
791
|
+
if (!byDepth.has(d)) byDepth.set(d, []);
|
|
792
|
+
byDepth.get(d).push(f);
|
|
793
|
+
}
|
|
730
794
|
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
795
|
+
for (const depth of [...byDepth.keys()].sort((a, b) => a - b)) {
|
|
796
|
+
const level = byDepth.get(depth);
|
|
797
|
+
const results = await sync.runPool(level, throttle, async (folderPath) => {
|
|
798
|
+
const parts = folderPath.split('/');
|
|
799
|
+
const name = parts.pop();
|
|
800
|
+
const creds = await mgr.valid();
|
|
801
|
+
await client.createFolder(projectId, name, parts.join('/'), creds);
|
|
802
|
+
return folderPath;
|
|
803
|
+
});
|
|
804
|
+
for (const r of results) {
|
|
805
|
+
if (r && r.__error) {
|
|
806
|
+
failedFolders.push({ folderPath: r.item, reason: r.__error });
|
|
807
|
+
ui.error(`carpeta ${r.item} — ${r.__error}`);
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
}
|
|
736
811
|
} else {
|
|
737
|
-
|
|
812
|
+
// Árbol incompleto: no sabemos qué existe. Volvemos al camino conservador.
|
|
813
|
+
const ensuredCache = new Set();
|
|
814
|
+
for (const u of plan.added) {
|
|
815
|
+
try {
|
|
816
|
+
const creds = await mgr.valid();
|
|
817
|
+
await client.ensurePath(projectId, u.relPath, creds, ensuredCache);
|
|
818
|
+
} catch (e) {
|
|
819
|
+
failedFolders.push({ folderPath: path.posix.dirname(u.relPath), reason: e.message });
|
|
820
|
+
ui.error(`${u.relPath} — ${e.message}`);
|
|
821
|
+
}
|
|
822
|
+
}
|
|
738
823
|
}
|
|
739
|
-
|
|
824
|
+
config.persistCredsIfChanged(cfg, mgr.current);
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
const blocked = (relPath) =>
|
|
828
|
+
failedFolders.some((f) => f.folderPath && relPath.startsWith(`${f.folderPath}/`));
|
|
829
|
+
|
|
830
|
+
// 8b) Subidas. El costo dominante no es el trabajo del servidor sino la
|
|
831
|
+
// latencia de cada round-trip, así que van agrupadas: el endpoint de lote
|
|
832
|
+
// escribe hasta 100 archivos por request y crea las carpetas que falten.
|
|
833
|
+
// El contenido se lee lote por lote, no todo de una: un repo grande no
|
|
834
|
+
// tiene por qué entrar entero en memoria.
|
|
835
|
+
const BATCH_MAX_FILES = COMMIT_BATCH; // 100: mismo techo que el commit
|
|
836
|
+
const BATCH_MAX_BYTES = 4 * 1024 * 1024; // el server corta en 5 MB; margen
|
|
837
|
+
|
|
838
|
+
const toUpload = [
|
|
839
|
+
...plan.added.map((u) => ({ u, isNew: true })),
|
|
840
|
+
...plan.modified.map((u) => ({ u, isNew: false })),
|
|
841
|
+
];
|
|
842
|
+
|
|
843
|
+
// Empaquetado por cantidad Y por tamaño. Un archivo más grande que el
|
|
844
|
+
// presupuesto termina solo en su lote, y ahí se sube por el endpoint
|
|
845
|
+
// individual.
|
|
846
|
+
//
|
|
847
|
+
// Los lotes son HOMOGÉNEOS: o todo texto, o todo binario. Los dos grupos se
|
|
848
|
+
// escriben por caminos distintos —lote JSON contra URL firmada— y un lote
|
|
849
|
+
// mezclado obligaría a partirlo en el momento de escribir, justo donde después
|
|
850
|
+
// hay que volver a juntar los cambios para un solo commit. Separarlos acá
|
|
851
|
+
// deja esa parte sin ramas.
|
|
852
|
+
//
|
|
853
|
+
// El presupuesto de bytes no se le aplica a los binarios: su contenido nunca
|
|
854
|
+
// entra en el payload de un request, así que un lote de diez PDF de 30 MB son
|
|
855
|
+
// diez subidas directas y ningún JSON grande.
|
|
856
|
+
const batches = [];
|
|
857
|
+
for (const group of [toUpload.filter((e) => !e.u.binary), toUpload.filter((e) => e.u.binary)]) {
|
|
858
|
+
let current = [];
|
|
859
|
+
let currentBytes = 0;
|
|
860
|
+
for (const e of group) {
|
|
861
|
+
const bytes = e.u.binary ? 0 : typeof e.u.size === 'number' ? e.u.size : 0;
|
|
862
|
+
const full =
|
|
863
|
+
current.length >= BATCH_MAX_FILES || currentBytes + bytes > BATCH_MAX_BYTES;
|
|
864
|
+
if (current.length > 0 && full) {
|
|
865
|
+
batches.push(current);
|
|
866
|
+
current = [];
|
|
867
|
+
currentBytes = 0;
|
|
868
|
+
}
|
|
869
|
+
current.push(e);
|
|
870
|
+
currentBytes += bytes;
|
|
871
|
+
}
|
|
872
|
+
if (current.length > 0) batches.push(current);
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
const upProg = ui.progress(toUpload.length, 'subiendo');
|
|
876
|
+
let upDone = 0;
|
|
877
|
+
const tickUp = (n = 1) => {
|
|
878
|
+
upDone += n;
|
|
879
|
+
upProg.update(upDone);
|
|
880
|
+
};
|
|
881
|
+
|
|
882
|
+
const uploaded = []; // { relPath, size, mtimeMs, hash } para el índice
|
|
883
|
+
let batchSupported = true; // se descubre en el primer lote
|
|
884
|
+
|
|
885
|
+
/** Escribe un lote entero en un request. Devuelve un resultado por archivo. */
|
|
886
|
+
async function writeViaBatch(entries) {
|
|
887
|
+
const payload = entries.map((e) => ({
|
|
888
|
+
path: e.u.relPath,
|
|
889
|
+
content: e.content,
|
|
890
|
+
mimeType: e.u.mimeType,
|
|
891
|
+
}));
|
|
892
|
+
const creds = await mgr.valid();
|
|
893
|
+
const { results } = await client.writeBatch(projectId, payload, creds);
|
|
740
894
|
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
895
|
+
return entries.map((e, i) => {
|
|
896
|
+
const r = results[i];
|
|
897
|
+
if (!r || !r.ok) {
|
|
898
|
+
return { ok: false, reason: (r && r.error) || 'el servidor no devolvió resultado' };
|
|
899
|
+
}
|
|
900
|
+
const f = r.file || {};
|
|
901
|
+
const action = r.action === 'created' ? 'added' : 'modified';
|
|
902
|
+
const change = { file_id: f.id, action, new_storage_path: f.storage_path };
|
|
903
|
+
if (action === 'modified') {
|
|
904
|
+
// El backend devuelve el storage_path que acaba de reemplazar; sólo si
|
|
905
|
+
// faltara caemos a lo que sabíamos del árbol remoto.
|
|
906
|
+
change.previous_storage_path =
|
|
907
|
+
f.previous_storage_path || (e.u.remote && e.u.remote.storage_path) || f.storage_path;
|
|
908
|
+
// El hash del contenido que estamos reemplazando lo sabe únicamente el
|
|
909
|
+
// cliente: cuando el commit llega al servidor, `files.content_hash` ya
|
|
910
|
+
// fue pisado con el contenido nuevo. Puede ser null en archivos
|
|
911
|
+
// anteriores a la migración 010.
|
|
912
|
+
if (e.u.remote && e.u.remote.content_hash) {
|
|
913
|
+
change.previous_content_hash = e.u.remote.content_hash;
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
return { ok: true, change, symbol: action === 'added' ? '+' : '~' };
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
/**
|
|
921
|
+
* Camino de los binarios: URL firmada, un archivo a la vez y en paralelo.
|
|
922
|
+
*
|
|
923
|
+
* No se agrupan en un request como el texto porque no hay nada que agrupar:
|
|
924
|
+
* cada byte va directo a Storage. Lo que sí se comparte es el control de
|
|
925
|
+
* ritmo, que es el que evita que veinte subidas de 30 MB salgan juntas.
|
|
926
|
+
*
|
|
927
|
+
* El endpoint crea las carpetas intermedias por su cuenta, igual que el lote,
|
|
928
|
+
* así que acá no hace falta `ensureFolders()`.
|
|
929
|
+
*/
|
|
930
|
+
async function writeViaDirect(entries) {
|
|
931
|
+
const results = await sync.runPool(entries, throttle, async (e) => {
|
|
744
932
|
const creds = await mgr.valid();
|
|
745
|
-
const
|
|
746
|
-
|
|
933
|
+
const { result } = await client.uploadBinary(
|
|
934
|
+
projectId,
|
|
935
|
+
{
|
|
936
|
+
path: e.u.relPath,
|
|
937
|
+
buffer: e.content,
|
|
938
|
+
contentHash: pushindex.hashContent(e.content),
|
|
939
|
+
mimeType: e.u.mimeType,
|
|
940
|
+
},
|
|
941
|
+
creds
|
|
942
|
+
);
|
|
943
|
+
|
|
944
|
+
const f = (result && result.file) || {};
|
|
945
|
+
const action = result && result.action === 'created' ? 'added' : 'modified';
|
|
946
|
+
const change = { file_id: f.id, action, new_storage_path: f.storage_path };
|
|
947
|
+
|
|
948
|
+
if (action === 'modified') {
|
|
949
|
+
change.previous_storage_path =
|
|
950
|
+
f.previous_storage_path || (e.u.remote && e.u.remote.storage_path) || f.storage_path;
|
|
951
|
+
// El hash de lo que estamos reemplazando: el servidor lo lee antes de
|
|
952
|
+
// pisar la fila, que es el único momento en que existe. Si no vino, lo
|
|
953
|
+
// sabemos por el árbol remoto.
|
|
954
|
+
const previous =
|
|
955
|
+
f.previous_content_hash || (e.u.remote && e.u.remote.content_hash) || null;
|
|
956
|
+
if (previous) change.previous_content_hash = previous;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
return { ok: true, change, symbol: action === 'added' ? '+' : '~' };
|
|
960
|
+
});
|
|
961
|
+
|
|
962
|
+
return results.map((r) => (r && r.__error ? { ok: false, reason: r.__error } : r));
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
/** Camino archivo-por-archivo: server sin /batch, o archivo demasiado grande. */
|
|
966
|
+
async function writeViaSingle(entries) {
|
|
967
|
+
await ensureFolders();
|
|
968
|
+
|
|
969
|
+
const outcomes = new Array(entries.length);
|
|
970
|
+
const queue = [];
|
|
971
|
+
entries.forEach((e, i) => {
|
|
972
|
+
if (e.isNew && blocked(e.u.relPath)) {
|
|
973
|
+
outcomes[i] = { ok: false, reason: 'no se pudo crear su carpeta' };
|
|
974
|
+
} else {
|
|
975
|
+
queue.push({ e, i });
|
|
976
|
+
}
|
|
977
|
+
});
|
|
978
|
+
|
|
979
|
+
const results = await sync.runPool(queue, throttle, async ({ e }) => {
|
|
980
|
+
const u = e.u;
|
|
981
|
+
if (e.isNew) {
|
|
982
|
+
const creds = await mgr.valid();
|
|
983
|
+
const created = await client.createFile(projectId, u.relPath, e.content, u.mimeType, creds);
|
|
984
|
+
if (created.created && created.file) {
|
|
985
|
+
return {
|
|
986
|
+
ok: true,
|
|
987
|
+
symbol: '+',
|
|
988
|
+
change: {
|
|
989
|
+
file_id: created.file.id,
|
|
990
|
+
action: 'added',
|
|
991
|
+
new_storage_path: created.file.storage_path,
|
|
992
|
+
},
|
|
993
|
+
};
|
|
994
|
+
}
|
|
995
|
+
// 409: apareció entremedio; caemos a update.
|
|
996
|
+
const writeCreds = await mgr.valid();
|
|
997
|
+
const upd = await client.writeFile(projectId, u.relPath, e.content, u.mimeType, writeCreds);
|
|
747
998
|
return {
|
|
748
999
|
ok: true,
|
|
749
|
-
symbol: '
|
|
1000
|
+
symbol: '~',
|
|
750
1001
|
change: {
|
|
751
|
-
file_id:
|
|
752
|
-
action: '
|
|
753
|
-
|
|
1002
|
+
file_id: upd.file.id,
|
|
1003
|
+
action: 'modified',
|
|
1004
|
+
previous_storage_path:
|
|
1005
|
+
(created.file && created.file.storage_path) || upd.file.storage_path,
|
|
1006
|
+
new_storage_path: upd.file.storage_path,
|
|
754
1007
|
},
|
|
755
1008
|
};
|
|
756
1009
|
}
|
|
757
|
-
|
|
758
|
-
const
|
|
759
|
-
const upd = await client.writeFile(projectId, u.relPath, e.content, u.mimeType,
|
|
1010
|
+
|
|
1011
|
+
const creds = await mgr.valid();
|
|
1012
|
+
const upd = await client.writeFile(projectId, u.relPath, e.content, u.mimeType, creds);
|
|
760
1013
|
return {
|
|
761
1014
|
ok: true,
|
|
762
1015
|
symbol: '~',
|
|
763
1016
|
change: {
|
|
764
1017
|
file_id: upd.file.id,
|
|
765
1018
|
action: 'modified',
|
|
766
|
-
previous_storage_path:
|
|
767
|
-
(created.file && created.file.storage_path) || upd.file.storage_path,
|
|
1019
|
+
previous_storage_path: u.remote ? u.remote.storage_path : upd.file.storage_path,
|
|
768
1020
|
new_storage_path: upd.file.storage_path,
|
|
1021
|
+
// Ver la nota en writeViaBatch: el hash previo sólo existe acá.
|
|
1022
|
+
...(u.remote && u.remote.content_hash
|
|
1023
|
+
? { previous_content_hash: u.remote.content_hash }
|
|
1024
|
+
: {}),
|
|
769
1025
|
},
|
|
770
1026
|
};
|
|
771
|
-
}
|
|
772
|
-
|
|
773
|
-
const creds = await mgr.valid();
|
|
774
|
-
const upd = await client.writeFile(projectId, u.relPath, e.content, u.mimeType, creds);
|
|
775
|
-
return {
|
|
776
|
-
ok: true,
|
|
777
|
-
symbol: '~',
|
|
778
|
-
change: {
|
|
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,
|
|
783
|
-
},
|
|
784
|
-
};
|
|
785
|
-
});
|
|
1027
|
+
});
|
|
786
1028
|
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
1029
|
+
// `runPool` devuelve los resultados alineados con la entrada, así que el
|
|
1030
|
+
// índice de `queue` alcanza para volver a la posición original.
|
|
1031
|
+
for (let k = 0; k < results.length; k += 1) {
|
|
1032
|
+
const r = results[k];
|
|
1033
|
+
outcomes[queue[k].i] = r && r.__error ? { ok: false, reason: r.__error } : r;
|
|
1034
|
+
}
|
|
1035
|
+
return outcomes;
|
|
792
1036
|
}
|
|
793
|
-
return outcomes;
|
|
794
|
-
}
|
|
795
1037
|
|
|
796
|
-
|
|
797
|
-
|
|
1038
|
+
for (let bi = 0; bi < batches.length; bi += 1) {
|
|
1039
|
+
const entries = batches[bi];
|
|
798
1040
|
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
1041
|
+
// Un fallo de lectura (dejó de ser texto, permisos) saca a ese archivo del
|
|
1042
|
+
// lote pero no arrastra a los demás.
|
|
1043
|
+
const loaded = [];
|
|
1044
|
+
for (const e of entries) {
|
|
1045
|
+
try {
|
|
1046
|
+
e.content = await loadContent(e.u);
|
|
1047
|
+
loaded.push(e);
|
|
1048
|
+
} catch (err) {
|
|
1049
|
+
failedWrites.push({ relPath: e.u.relPath, reason: err.message });
|
|
1050
|
+
logError(upProg, `${e.u.relPath} — ${err.message}`);
|
|
1051
|
+
tickUp();
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
if (loaded.length === 0) continue;
|
|
1055
|
+
|
|
1056
|
+
// El lote se armó con lo que dijo el preflight, que sólo miró los primeros
|
|
1057
|
+
// 8 KB. `loadContent` acaba de leer el archivo entero y pudo reclasificarlo,
|
|
1058
|
+
// así que la partición se rehace ACÁ, sobre el contenido de verdad. Mandar
|
|
1059
|
+
// un Buffer por el lote JSON lo convertiría en `{"type":"Buffer",...}`, que
|
|
1060
|
+
// es la corrupción silenciosa que toda esta etapa existe para evitar.
|
|
1061
|
+
const binaries = loaded.filter((e) => e.u.binary);
|
|
1062
|
+
const texts = loaded.filter((e) => !e.u.binary);
|
|
1063
|
+
|
|
1064
|
+
const outcomeByEntry = new Map();
|
|
1065
|
+
|
|
1066
|
+
if (binaries.length > 0) {
|
|
1067
|
+
// Un fallo acá es por archivo, no del lote: `writeViaDirect` devuelve un
|
|
1068
|
+
// resultado por entrada y los errores ya vienen adentro.
|
|
1069
|
+
const res = await writeViaDirect(binaries);
|
|
1070
|
+
binaries.forEach((e, i) => outcomeByEntry.set(e, res[i]));
|
|
810
1071
|
}
|
|
811
|
-
}
|
|
812
|
-
if (loaded.length === 0) continue;
|
|
813
|
-
|
|
814
|
-
const oversize =
|
|
815
|
-
loaded.length === 1 && Buffer.byteLength(loaded[0].content, 'utf8') > BATCH_MAX_BYTES;
|
|
816
|
-
let outcomes = null;
|
|
817
1072
|
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
1073
|
+
if (texts.length > 0) {
|
|
1074
|
+
const oversize =
|
|
1075
|
+
texts.length === 1 && Buffer.byteLength(texts[0].content, 'utf8') > BATCH_MAX_BYTES;
|
|
1076
|
+
let res = null;
|
|
1077
|
+
|
|
1078
|
+
if (batchSupported && !oversize) {
|
|
1079
|
+
try {
|
|
1080
|
+
res = await writeViaBatch(texts);
|
|
1081
|
+
} catch (err) {
|
|
1082
|
+
if (err.notSupported) {
|
|
1083
|
+
batchSupported = false;
|
|
1084
|
+
upProg.clear();
|
|
1085
|
+
ui.warn('El servidor no acepta escritura por lotes; se sube archivo por archivo.');
|
|
1086
|
+
} else {
|
|
1087
|
+
// Falla del request entero (red, 5xx, permisos): ningún archivo de
|
|
1088
|
+
// texto del lote se escribió. Los binarios ya se resolvieron aparte
|
|
1089
|
+
// y no los arrastra.
|
|
1090
|
+
res = texts.map(() => ({ ok: false, reason: err.message }));
|
|
1091
|
+
}
|
|
832
1092
|
}
|
|
833
|
-
tickUp(loaded.length);
|
|
834
|
-
continue;
|
|
835
1093
|
}
|
|
836
|
-
}
|
|
837
|
-
}
|
|
838
1094
|
|
|
839
|
-
|
|
840
|
-
|
|
1095
|
+
if (!res) res = await writeViaSingle(texts);
|
|
1096
|
+
texts.forEach((e, i) => outcomeByEntry.set(e, res[i]));
|
|
1097
|
+
}
|
|
841
1098
|
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
const
|
|
846
|
-
|
|
847
|
-
const
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
1099
|
+
const outcomes = loaded.map((e) => outcomeByEntry.get(e));
|
|
1100
|
+
config.persistCredsIfChanged(cfg, mgr.current);
|
|
1101
|
+
|
|
1102
|
+
const batchChanges = [];
|
|
1103
|
+
for (let i = 0; i < loaded.length; i += 1) {
|
|
1104
|
+
const e = loaded[i];
|
|
1105
|
+
const o = outcomes[i];
|
|
1106
|
+
if (!o || !o.ok) {
|
|
1107
|
+
const reason = (o && o.reason) || 'error desconocido';
|
|
1108
|
+
failedWrites.push({ relPath: e.u.relPath, reason });
|
|
1109
|
+
logError(upProg, `${e.u.relPath} — ${reason}`);
|
|
1110
|
+
continue;
|
|
1111
|
+
}
|
|
1112
|
+
batchChanges.push(o.change);
|
|
1113
|
+
uploaded.push({ ...e.u, hash: pushindex.hashContent(e.content) });
|
|
1114
|
+
logFile(upProg, `${ui.c[o.symbol === '+' ? 'green' : 'yellow'](o.symbol)} ${e.u.relPath}`);
|
|
851
1115
|
}
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
} catch (e) {
|
|
879
|
-
ui.error(`Fallo al crear el commit (lote ${partNo}/${batches.length}): ${e.message}`);
|
|
880
|
-
failedWrites.push({ relPath: `<commit lote ${partNo}>`, reason: e.message });
|
|
1116
|
+
tickUp(loaded.length);
|
|
1117
|
+
|
|
1118
|
+
// 9) Commit del lote, apenas termina de escribirse. Juntar todos los cambios
|
|
1119
|
+
// para un commit final ahorraba requests, pero dejaba una ventana larga
|
|
1120
|
+
// en la que los archivos ya estaban escritos y ningún commit los
|
|
1121
|
+
// mencionaba; si el push moría ahí, el historial quedaba mintiendo.
|
|
1122
|
+
if (batchChanges.length > 0) {
|
|
1123
|
+
const partNo = bi + 1;
|
|
1124
|
+
const msg = buildMessage(
|
|
1125
|
+
message,
|
|
1126
|
+
batchChanges.length,
|
|
1127
|
+
toUpload.length,
|
|
1128
|
+
partNo,
|
|
1129
|
+
batches.length,
|
|
1130
|
+
card
|
|
1131
|
+
);
|
|
1132
|
+
try {
|
|
1133
|
+
const creds = await mgr.valid();
|
|
1134
|
+
await client.createCommit(projectId, msg, batchChanges, creds);
|
|
1135
|
+
committed += batchChanges.length;
|
|
1136
|
+
commitCount += 1;
|
|
1137
|
+
config.persistCredsIfChanged(cfg, mgr.current);
|
|
1138
|
+
} catch (e) {
|
|
1139
|
+
ui.error(`Fallo al crear el commit (lote ${partNo}/${batches.length}): ${e.message}`);
|
|
1140
|
+
failedWrites.push({ relPath: `<commit lote ${partNo}>`, reason: e.message });
|
|
1141
|
+
}
|
|
881
1142
|
}
|
|
882
|
-
}
|
|
883
1143
|
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
1144
|
+
// El contenido ya se hasheó y se subió: soltarlo mantiene el pico de memoria
|
|
1145
|
+
// atado al tamaño del lote y no al del repo.
|
|
1146
|
+
for (const e of loaded) {
|
|
1147
|
+
e.content = null;
|
|
1148
|
+
e.u.content = null;
|
|
1149
|
+
}
|
|
889
1150
|
}
|
|
1151
|
+
upProg.clear();
|
|
1152
|
+
|
|
1153
|
+
config.persistCredsIfChanged(cfg, mgr.current);
|
|
1154
|
+
return { failedWrites, committed, commitCount, uploaded, open: [], stale: 0, main: null, base: null };
|
|
890
1155
|
}
|
|
891
|
-
|
|
1156
|
+
|
|
1157
|
+
let outcome = await pushViaProposals();
|
|
1158
|
+
const legacy = outcome === null;
|
|
1159
|
+
if (legacy) {
|
|
1160
|
+
ui.warn(
|
|
1161
|
+
'Este servidor todavía no recibe propuestas: se sube por el camino anterior, ' +
|
|
1162
|
+
'que puede pisar cambios de otras personas.'
|
|
1163
|
+
);
|
|
1164
|
+
outcome = await pushLegacy();
|
|
1165
|
+
}
|
|
1166
|
+
const { failedWrites, committed, commitCount, uploaded, open, stale, main } = outcome;
|
|
892
1167
|
|
|
893
1168
|
config.persistCredsIfChanged(cfg, mgr.current);
|
|
894
1169
|
|
|
895
1170
|
// 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.
|
|
1171
|
+
// push arranca sabiendo qué no hace falta ni leer. Lo que quedó en una
|
|
1172
|
+
// propuesta abierta también se anota: su hash no coincide con el de la
|
|
1173
|
+
// versión actual, así que el push siguiente lo vuelve a mirar igual.
|
|
897
1174
|
if (useIndex) {
|
|
898
1175
|
for (const u of uploaded) {
|
|
899
1176
|
pushindex.record(index, u, pushedAt);
|
|
@@ -901,35 +1178,115 @@ async function push(args) {
|
|
|
901
1178
|
saveIndex(dir, index, plan, uploads, pushedAt);
|
|
902
1179
|
}
|
|
903
1180
|
|
|
904
|
-
// 11) Anotar la sincronización
|
|
905
|
-
//
|
|
906
|
-
//
|
|
907
|
-
|
|
908
|
-
|
|
1181
|
+
// 11) Anotar la sincronización y la base nueva. Sólo si TODO quedó en la
|
|
1182
|
+
// versión actual: con archivos fallados o propuestas abiertas, la carpeta
|
|
1183
|
+
// no está alineada, y mover la base haría que el push siguiente proponga
|
|
1184
|
+
// esos mismos archivos como si nadie más los hubiera tocado.
|
|
1185
|
+
const clean = failedWrites.length === 0 && plan.failed.length === 0 && open.length === 0;
|
|
1186
|
+
if (clean) {
|
|
1187
|
+
remote.touchSync(dir, { projectId, baseURL, base: legacy ? null : main });
|
|
909
1188
|
}
|
|
910
1189
|
|
|
911
1190
|
// 12) Resumen final.
|
|
912
1191
|
ui.info('');
|
|
913
|
-
|
|
914
|
-
|
|
1192
|
+
for (const o of open) reportOpen(o);
|
|
1193
|
+
if (stale > 0) {
|
|
1194
|
+
ui.warn(
|
|
1195
|
+
`${stale} archivo(s) ya no cambian nada: la versión actual avanzó y lo que tenés ` +
|
|
1196
|
+
'es lo de antes. Traé lo nuevo con "shilo pull" antes de volver a subir.'
|
|
1197
|
+
);
|
|
1198
|
+
}
|
|
1199
|
+
if (clean) {
|
|
1200
|
+
if (committed === 0 && stale === 0) {
|
|
1201
|
+
ui.success('Todo al día. Nada para subir.');
|
|
1202
|
+
} else if (legacy) {
|
|
1203
|
+
ui.success(`Push completo: ${committed} archivo(s) en ${commitCount} commit(s).`);
|
|
1204
|
+
} else {
|
|
1205
|
+
ui.success(`Push completo: ${committed} archivo(s) ya son parte de la versión actual.`);
|
|
1206
|
+
}
|
|
915
1207
|
return 0;
|
|
916
1208
|
}
|
|
917
1209
|
for (const f of plan.failed) ui.error(`${f.relPath} — ${f.reason}`);
|
|
918
|
-
|
|
919
|
-
`Push con problemas: ${failedWrites.length + plan.failed.length} archivo(s)
|
|
920
|
-
|
|
1210
|
+
if (failedWrites.length + plan.failed.length > 0) {
|
|
1211
|
+
ui.warn(`Push con problemas: ${failedWrites.length + plan.failed.length} archivo(s) fallaron.`);
|
|
1212
|
+
} else {
|
|
1213
|
+
ui.warn(
|
|
1214
|
+
`${committed} archivo(s) subieron; ${open.reduce((n, o) => n + o.paths.length, 0)} quedaron en ` +
|
|
1215
|
+
`${open.length} propuesta(s) abierta(s).`
|
|
1216
|
+
);
|
|
1217
|
+
}
|
|
921
1218
|
return 1;
|
|
922
1219
|
}
|
|
923
1220
|
|
|
1221
|
+
/**
|
|
1222
|
+
* Cuenta por qué una propuesta quedó abierta, en el vocabulario del glosario:
|
|
1223
|
+
* nunca "merge" ni "conflicto de rama", siempre qué archivo y quién.
|
|
1224
|
+
*/
|
|
1225
|
+
function reportOpen(o) {
|
|
1226
|
+
const id = o.proposal ? o.proposal.id.slice(0, 8) : '?';
|
|
1227
|
+
if (o.conflicts.length > 0) {
|
|
1228
|
+
ui.warn(
|
|
1229
|
+
`${o.conflicts.length} archivo(s) los cambió otra persona desde tu última sincronización, ` +
|
|
1230
|
+
'y vos también:'
|
|
1231
|
+
);
|
|
1232
|
+
for (const c of o.conflicts) {
|
|
1233
|
+
const why =
|
|
1234
|
+
c.kind === 'archivo-y-carpeta'
|
|
1235
|
+
? 'es archivo de un lado y carpeta del otro'
|
|
1236
|
+
: c.ours === null
|
|
1237
|
+
? 'vos lo borraste y en la versión actual cambió'
|
|
1238
|
+
: c.theirs === null
|
|
1239
|
+
? 'lo borraron en la versión actual y vos lo cambiaste'
|
|
1240
|
+
: 'las dos versiones son distintas';
|
|
1241
|
+
ui.info(` ${ui.c.red('!')} ${c.path} ${ui.c.dim(`— ${why}`)}`);
|
|
1242
|
+
}
|
|
1243
|
+
} else if (o.blockedBy.length > 0) {
|
|
1244
|
+
ui.warn('Otra propuesta abierta toca los mismos archivos:');
|
|
1245
|
+
for (const b of o.blockedBy) {
|
|
1246
|
+
ui.info(` ${ui.c.yellow('·')} "${b.title || b.id.slice(0, 8)}" ${ui.c.dim(`— ${b.paths.join(', ')}`)}`);
|
|
1247
|
+
}
|
|
1248
|
+
} else {
|
|
1249
|
+
ui.warn(o.message || 'La propuesta quedó abierta.');
|
|
1250
|
+
}
|
|
1251
|
+
ui.info(
|
|
1252
|
+
` Tus ${o.paths.length} archivo(s) no se perdieron: quedaron como propuesta ${ui.c.bold(id)}, ` +
|
|
1253
|
+
'abierta para que el dueño del proyecto decida.'
|
|
1254
|
+
);
|
|
1255
|
+
ui.info(
|
|
1256
|
+
ui.c.dim(' Para resolverlo vos: "shilo pull" trae la versión actual (no pisa lo tuyo sin --force), ') +
|
|
1257
|
+
ui.c.dim('ajustá y volvé a "shilo push".')
|
|
1258
|
+
);
|
|
1259
|
+
ui.info('');
|
|
1260
|
+
}
|
|
1261
|
+
|
|
924
1262
|
/**
|
|
925
1263
|
* Lee el contenido completo de un archivo, cacheado en la propia entrada.
|
|
926
|
-
*
|
|
1264
|
+
*
|
|
1265
|
+
* Devuelve un `string` para texto y un `Buffer` para binarios. Esa diferencia
|
|
1266
|
+
* es la que decide por qué camino sube el archivo, así que se re-clasifica acá y
|
|
1267
|
+
* no se confía en lo que dijo el preflight: entre una cosa y la otra el archivo
|
|
1268
|
+
* pudo cambiar, y el preflight sólo miró los primeros 8 KB.
|
|
1269
|
+
*
|
|
1270
|
+
* `u.binary` y `u.mimeType` se corrigen con lo que digan los bytes completos.
|
|
1271
|
+
* Un archivo que el preflight leyó como texto y resultó binario tiene que
|
|
1272
|
+
* cambiar de camino, no subirse mal por el que ya tenía asignado.
|
|
927
1273
|
*/
|
|
928
1274
|
async function loadContent(u) {
|
|
929
1275
|
if (u.content != null) return u.content;
|
|
1276
|
+
|
|
930
1277
|
const buf = await fs.promises.readFile(u.absPath);
|
|
931
1278
|
const cls = classifyContent(path.basename(u.relPath), buf);
|
|
1279
|
+
|
|
1280
|
+
if (cls.isBinary) {
|
|
1281
|
+
u.binary = true;
|
|
1282
|
+
u.mimeType = cls.mimeType;
|
|
1283
|
+
u.content = cls.buffer;
|
|
1284
|
+
return u.content;
|
|
1285
|
+
}
|
|
1286
|
+
|
|
932
1287
|
if (!cls.isText) throw new Error(cls.reason);
|
|
1288
|
+
|
|
1289
|
+
u.binary = false;
|
|
933
1290
|
u.content = cls.content;
|
|
934
1291
|
return u.content;
|
|
935
1292
|
}
|