shilo-cli 0.6.0 → 0.8.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 +319 -243
- package/bin/shilo.js +197 -165
- package/package.json +1 -1
- package/src/ShiloClient.js +1027 -706
- package/src/args.js +32 -8
- package/src/commands/diff.js +18 -2
- package/src/commands/export.js +226 -0
- package/src/commands/log.js +4 -0
- package/src/commands/pull.js +105 -5
- package/src/commands/push.js +848 -394
- package/src/commands/review.js +101 -0
- package/src/commands/status.js +36 -0
- package/src/exportgit.js +417 -0
- package/src/gitobjects.js +532 -0
- package/src/remote.js +15 -3
- package/src/sync.js +4 -1
- package/src/walk.js +30 -0
package/src/commands/push.js
CHANGED
|
@@ -21,7 +21,14 @@ const {
|
|
|
21
21
|
const { ShiloClient } = require('../ShiloClient');
|
|
22
22
|
|
|
23
23
|
const DEFAULT_BASE_URL = 'https://www.shilo.city';
|
|
24
|
-
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
|
|
25
32
|
|
|
26
33
|
// Cuánto se lee de cada archivo en el preflight: alcanza para decidir binario
|
|
27
34
|
// (looksBinary mira 8000 bytes) y para escanear secretos, sin cargar el repo
|
|
@@ -29,6 +36,11 @@ const COMMIT_BATCH = 100; // máximo de archivos por commit (límite del backend
|
|
|
29
36
|
const HEAD_BYTES = 8192;
|
|
30
37
|
|
|
31
38
|
// Cuántos ejemplos se muestran por cada motivo de salteo.
|
|
39
|
+
// Techo por campo del bloque `agent` y del prompt (Etapa 4, puntos 1 y 2). El
|
|
40
|
+
// del prompt es el mismo que aplica el servidor: avisar acá ahorra el viaje.
|
|
41
|
+
const AGENT_FIELD_MAX = 200;
|
|
42
|
+
const PROMPT_MAX_BYTES = 64 * 1024;
|
|
43
|
+
|
|
32
44
|
const SKIP_EXAMPLES = 5;
|
|
33
45
|
// A partir de cuántos salteos en una misma carpeta de primer nivel sugerimos
|
|
34
46
|
// ignorarla entera.
|
|
@@ -142,6 +154,61 @@ function preflight(dir) {
|
|
|
142
154
|
return { uploads, skipped: skips, ignored, gitDirs, findings };
|
|
143
155
|
}
|
|
144
156
|
|
|
157
|
+
/**
|
|
158
|
+
* Aplica los selectores de `--only` sobre los archivos que dejó el preflight.
|
|
159
|
+
*
|
|
160
|
+
* Cada selector se normaliza con `sync.toRepoPath`, que ya blinda contra salir
|
|
161
|
+
* de la carpeta, y matchea por igualdad exacta o como prefijo de carpeta
|
|
162
|
+
* (`Documentacion/` alcanza todo lo de adentro).
|
|
163
|
+
*
|
|
164
|
+
* Un selector que no matchea nada es ERROR, no un push silencioso de todo:
|
|
165
|
+
* confundir "no subí nada de lo que pedí" con "subí el repo entero" es
|
|
166
|
+
* exactamente el fallo que arruinaría la confianza en el flag.
|
|
167
|
+
*
|
|
168
|
+
* Devuelve `{ uploads, leftOut }` o `{ error }`.
|
|
169
|
+
*/
|
|
170
|
+
function selectUploads(dir, allUploads, only) {
|
|
171
|
+
const selectors = (Array.isArray(only) ? only : only ? [only] : [])
|
|
172
|
+
.map((s) => String(s).trim())
|
|
173
|
+
.filter(Boolean);
|
|
174
|
+
|
|
175
|
+
if (selectors.length === 0) return { uploads: allUploads, leftOut: [] };
|
|
176
|
+
|
|
177
|
+
const normalized = [];
|
|
178
|
+
for (const raw of selectors) {
|
|
179
|
+
const repoPath = sync.toRepoPath(dir, raw);
|
|
180
|
+
if (!repoPath) return { error: `--only "${raw}" queda fuera de la carpeta del push.` };
|
|
181
|
+
normalized.push({ raw, repoPath });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const matched = new Set();
|
|
185
|
+
const uploads = [];
|
|
186
|
+
const leftOut = [];
|
|
187
|
+
|
|
188
|
+
for (const u of allUploads) {
|
|
189
|
+
const hit = normalized.find(
|
|
190
|
+
(s) => u.relPath === s.repoPath || u.relPath.startsWith(`${s.repoPath}/`)
|
|
191
|
+
);
|
|
192
|
+
if (hit) {
|
|
193
|
+
matched.add(hit.repoPath);
|
|
194
|
+
uploads.push(u);
|
|
195
|
+
} else {
|
|
196
|
+
leftOut.push(u);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const misses = normalized.filter((s) => !matched.has(s.repoPath));
|
|
201
|
+
if (misses.length > 0) {
|
|
202
|
+
return {
|
|
203
|
+
error:
|
|
204
|
+
`--only no encontró nada para: ${misses.map((s) => `"${s.raw}"`).join(', ')}. ` +
|
|
205
|
+
`Puede estar ignorado, salteado, o escrito distinto; nada se subió.`,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return { uploads, leftOut };
|
|
210
|
+
}
|
|
211
|
+
|
|
145
212
|
/**
|
|
146
213
|
* Reporte agrupado de salteos. Un repo con un `venv/` adentro generaba miles de
|
|
147
214
|
* líneas "saltado …" que tapaban todo lo demás; acá se resume por motivo, con
|
|
@@ -237,9 +304,11 @@ function neededFolders(relPaths) {
|
|
|
237
304
|
* shilo push [carpeta] [--project <uuid>] [-m <msg>] [--dry-run] [--yes]
|
|
238
305
|
* [--allow-secrets] [--concurrency <n>] [--no-index] [--verbose]
|
|
239
306
|
*
|
|
240
|
-
* Replica una carpeta local a un repo de Shilo: crea archivos nuevos
|
|
241
|
-
* los que cambiaron
|
|
242
|
-
*
|
|
307
|
+
* Replica una carpeta local a un repo de Shilo: crea archivos nuevos y actualiza
|
|
308
|
+
* los que cambiaron. Desde la Etapa 3 lo hace como PROPUESTA: el servidor la
|
|
309
|
+
* aplica si combina limpio con la versión actual, y si otra persona cambió
|
|
310
|
+
* alguno de los mismos archivos la deja abierta sin pisar nada. Con un servidor
|
|
311
|
+
* anterior cae al camino viejo (escribir + commit), que sí puede pisar.
|
|
243
312
|
*/
|
|
244
313
|
async function push(args) {
|
|
245
314
|
const dir = path.resolve(args._[0] || '.');
|
|
@@ -283,8 +352,26 @@ async function push(args) {
|
|
|
283
352
|
return 1;
|
|
284
353
|
}
|
|
285
354
|
|
|
355
|
+
// Procedencia (Etapa 4, punto 1). Sin --agent no se manda bloque alguno y el
|
|
356
|
+
// guardado queda como escrito por una persona, que es la verdad.
|
|
357
|
+
const agent = buildAgent(args);
|
|
358
|
+
if (agent && agent.error) {
|
|
359
|
+
ui.error(agent.error);
|
|
360
|
+
return 1;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const promptRead = readPrompt(args, agent);
|
|
364
|
+
if (promptRead.error) {
|
|
365
|
+
ui.error(promptRead.error);
|
|
366
|
+
return 1;
|
|
367
|
+
}
|
|
368
|
+
for (const w of promptRead.warnings) ui.warn(w);
|
|
369
|
+
const promptText = promptRead.prompt;
|
|
370
|
+
|
|
286
371
|
// 3) PREFLIGHT: todo offline. Recién después de esto se toca la red.
|
|
287
|
-
|
|
372
|
+
// `allUploads` es la carpeta entera; `--only` recorta DESPUÉS (ver 3b),
|
|
373
|
+
// para que los ignores y el escáner de secretos corran igual siempre.
|
|
374
|
+
const { uploads: allUploads, skipped, ignored, gitDirs, findings } = preflight(dir);
|
|
288
375
|
|
|
289
376
|
if (gitDirs.length > 0) {
|
|
290
377
|
ui.warn(
|
|
@@ -311,12 +398,23 @@ async function push(args) {
|
|
|
311
398
|
);
|
|
312
399
|
}
|
|
313
400
|
|
|
314
|
-
if (
|
|
401
|
+
if (allUploads.length === 0) {
|
|
315
402
|
ui.info('No hay archivos para subir.');
|
|
316
403
|
ui.info(ui.c.dim(`(${ignored} ignorados, ${skipped.length} salteados)`));
|
|
317
404
|
return 0;
|
|
318
405
|
}
|
|
319
406
|
|
|
407
|
+
// 3b) `--only`: recorta QUÉ se sube, no qué se mira. El push nunca borra
|
|
408
|
+
// archivos remotos (su plan es added/modified/unchanged/failed), así que
|
|
409
|
+
// subir un subconjunto es seguro por construcción: no hay semántica nueva.
|
|
410
|
+
const selection = selectUploads(dir, allUploads, args.only);
|
|
411
|
+
if (selection.error) {
|
|
412
|
+
ui.error(selection.error);
|
|
413
|
+
return 1;
|
|
414
|
+
}
|
|
415
|
+
const uploads = selection.uploads;
|
|
416
|
+
const leftOut = selection.leftOut;
|
|
417
|
+
|
|
320
418
|
// 4) Cliente + control de ritmo. `onThrottle` baja la concurrencia sola si el
|
|
321
419
|
// servidor empieza a responder 429, en vez de insistir hasta que corte.
|
|
322
420
|
const limit = sync.resolveConcurrency(args);
|
|
@@ -504,30 +602,9 @@ async function push(args) {
|
|
|
504
602
|
|
|
505
603
|
config.persistCredsIfChanged(cfg, mgr.current);
|
|
506
604
|
|
|
507
|
-
//
|
|
508
|
-
//
|
|
509
|
-
// compañero". Best-effort: si el historial no se puede leer, seguimos.
|
|
510
|
-
let attribution = { index: new Map() };
|
|
605
|
+
// Con token de acceso personal no sabemos el id del usuario; sólo importa
|
|
606
|
+
// para reconocer propuestas propias que este push reemplaza.
|
|
511
607
|
const myId = cfg.credentials && cfg.credentials.user ? cfg.credentials.user.id : null;
|
|
512
|
-
if (plan.modified.length > 0) {
|
|
513
|
-
attribution = await sync.buildAuthorIndex(client, projectId, mgr, { commits: 50 });
|
|
514
|
-
config.persistCredsIfChanged(cfg, mgr.current);
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
/** Etiqueta " — última edición de X, fecha" para un archivo del plan. */
|
|
518
|
-
const authorNote = (u) => {
|
|
519
|
-
const meta = u.remote && attribution.index.get(u.remote.id);
|
|
520
|
-
if (!meta) return '';
|
|
521
|
-
const mine = myId && meta.authorId && meta.authorId === myId;
|
|
522
|
-
const who = mine ? ui.c.dim('vos') : ui.c.bold(meta.author);
|
|
523
|
-
return ui.c.dim(' — última edición de ') + who + ui.c.dim(`, ${sync.formatDate(meta.date)}`);
|
|
524
|
-
};
|
|
525
|
-
|
|
526
|
-
/** ¿El último que tocó este archivo fue otra persona? */
|
|
527
|
-
const touchedByOthers = plan.modified.filter((u) => {
|
|
528
|
-
const meta = u.remote && attribution.index.get(u.remote.id);
|
|
529
|
-
return meta && meta.authorId && myId && meta.authorId !== myId;
|
|
530
|
-
});
|
|
531
608
|
|
|
532
609
|
// 6) Reporte del plan.
|
|
533
610
|
ui.info('');
|
|
@@ -545,45 +622,25 @@ async function push(args) {
|
|
|
545
622
|
for (const f of plan.failed) ui.error(`${f.relPath} — ${f.reason}`);
|
|
546
623
|
return 1;
|
|
547
624
|
}
|
|
548
|
-
if (useIndex && !dryRun) saveIndex(dir, index, plan,
|
|
625
|
+
if (useIndex && !dryRun) saveIndex(dir, index, plan, allUploads);
|
|
549
626
|
ui.success('Todo al día. Nada para subir.');
|
|
627
|
+
reportLeftOut(leftOut, remoteByPath);
|
|
550
628
|
return 0;
|
|
551
629
|
}
|
|
552
630
|
|
|
553
631
|
if (dryRun) {
|
|
554
632
|
for (const u of plan.added) ui.info(` ${ui.c.green('+')} ${u.relPath}`);
|
|
555
|
-
for (const u of plan.modified) ui.info(` ${ui.c.yellow('~')} ${u.relPath}
|
|
633
|
+
for (const u of plan.modified) ui.info(` ${ui.c.yellow('~')} ${u.relPath}`);
|
|
556
634
|
for (const f of plan.failed) ui.info(` ${ui.c.red('!')} ${f.relPath} — ${f.reason}`);
|
|
557
635
|
ui.info('');
|
|
558
|
-
|
|
559
|
-
ui.info(
|
|
560
|
-
ui.c.dim(
|
|
561
|
-
'(con token de acceso personal no sabemos tu id de usuario: ' +
|
|
562
|
-
'el autor se muestra, pero no se marca cuál sos vos)'
|
|
563
|
-
)
|
|
564
|
-
);
|
|
565
|
-
ui.info('');
|
|
566
|
-
}
|
|
567
|
-
if (touchedByOthers.length > 0) {
|
|
568
|
-
ui.warn(
|
|
569
|
-
`${touchedByOthers.length} de esos archivos los editó otra persona: ` +
|
|
570
|
-
`este push pisaría su versión.`
|
|
571
|
-
);
|
|
572
|
-
ui.info(ui.c.dim('Revisalos con "shilo diff <archivo>" o traelos con "shilo pull".'));
|
|
573
|
-
ui.info('');
|
|
574
|
-
}
|
|
636
|
+
reportLeftOut(leftOut, remoteByPath);
|
|
575
637
|
ui.info(ui.c.dim('(--dry-run: no se subió nada)'));
|
|
576
638
|
return plan.failed.length ? 1 : 0;
|
|
577
639
|
}
|
|
578
640
|
|
|
579
|
-
// 7) Confirmar (salvo --yes).
|
|
580
|
-
//
|
|
581
|
-
|
|
582
|
-
ui.warn(`Vas a pisar ${touchedByOthers.length} archivo(s) editado(s) por otra persona:`);
|
|
583
|
-
for (const u of touchedByOthers) ui.info(` ${ui.c.red('!')} ${u.relPath}${authorNote(u)}`);
|
|
584
|
-
ui.info('');
|
|
585
|
-
}
|
|
586
|
-
|
|
641
|
+
// 7) Confirmar (salvo --yes). Ya no hace falta advertir "vas a pisar trabajo
|
|
642
|
+
// ajeno": desde la Etapa 3 el servidor no deja pisar nada. Si alguien más
|
|
643
|
+
// cambió el mismo archivo, la propuesta queda abierta y se informa abajo.
|
|
587
644
|
if (!args.yes) {
|
|
588
645
|
const ok = await prompt.confirm(`¿Subir ${toWrite} archivo(s) al proyecto?`, true);
|
|
589
646
|
if (!ok) {
|
|
@@ -592,13 +649,7 @@ async function push(args) {
|
|
|
592
649
|
}
|
|
593
650
|
}
|
|
594
651
|
|
|
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.
|
|
598
|
-
const failedWrites = [];
|
|
599
652
|
const pushedAt = Date.now();
|
|
600
|
-
let committed = 0;
|
|
601
|
-
let commitCount = 0;
|
|
602
653
|
|
|
603
654
|
/** Log por archivo: en TTY lo tapa la barra de progreso, así que va sólo con --verbose. */
|
|
604
655
|
const logFile = (prog, msg) => {
|
|
@@ -614,422 +665,697 @@ async function push(args) {
|
|
|
614
665
|
ui.error(msg);
|
|
615
666
|
};
|
|
616
667
|
|
|
617
|
-
//
|
|
618
|
-
//
|
|
619
|
-
//
|
|
620
|
-
//
|
|
621
|
-
//
|
|
668
|
+
// 8) Proponer. Todo lo que cambió va al servidor como UNA propuesta (o varias
|
|
669
|
+
// si no entra en una: ver PROPOSAL_MAX_*) que parte de la versión que esta
|
|
670
|
+
// carpeta vio por última vez (`.shilo.json` → `base`). El servidor la
|
|
671
|
+
// aplica solo si combina limpio con la versión actual; si otra persona
|
|
672
|
+
// cambió alguno de los mismos archivos, la deja abierta y NO pisa nada.
|
|
622
673
|
//
|
|
623
|
-
//
|
|
624
|
-
//
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
async function
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
674
|
+
// El texto viaja inline; los binarios (y el texto que no entra en el JSON)
|
|
675
|
+
// se inventarian primero por URL firmada y entran por su oid.
|
|
676
|
+
//
|
|
677
|
+
// Devuelve null si el servidor no conoce las propuestas todavía: entonces
|
|
678
|
+
// se usa el camino anterior, que sí puede pisar.
|
|
679
|
+
async function pushViaProposals() {
|
|
680
|
+
let listed;
|
|
681
|
+
try {
|
|
682
|
+
listed = await client.listProposals(projectId, await mgr.valid(), { state: 'abierta' });
|
|
683
|
+
} catch (e) {
|
|
684
|
+
if (e.notSupported) return null;
|
|
685
|
+
throw e;
|
|
686
|
+
}
|
|
687
|
+
config.persistCredsIfChanged(cfg, mgr.current);
|
|
688
|
+
|
|
689
|
+
const knownBase = savedRemote && savedRemote.base ? savedRemote.base : null;
|
|
690
|
+
let base = knownBase || listed.main;
|
|
691
|
+
if (!knownBase && listed.main) {
|
|
692
|
+
ui.info(
|
|
693
|
+
ui.c.dim(
|
|
694
|
+
'Esta carpeta no tenía anotada de qué versión partió: se toma la actual. ' +
|
|
695
|
+
'Desde ahora el push sabrá si alguien cambió lo mismo que vos.'
|
|
696
|
+
)
|
|
636
697
|
);
|
|
637
|
-
|
|
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
|
-
}
|
|
698
|
+
}
|
|
643
699
|
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
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
|
-
}
|
|
700
|
+
// 8a) Propuestas propias que siguen abiertas: este push las reemplaza, porque
|
|
701
|
+
// la carpeta local es lo que la persona quiere que quede. Si se dejaran,
|
|
702
|
+
// bloquearían a la nueva por tocar los mismos archivos.
|
|
703
|
+
const mine = myId ? listed.proposals.filter((pr) => pr.author_id === myId) : [];
|
|
704
|
+
for (const pr of mine) {
|
|
705
|
+
try {
|
|
706
|
+
await client.resolveProposal(projectId, pr.id, 'discard', await mgr.valid());
|
|
707
|
+
ui.info(ui.c.dim(`Se descartó tu propuesta anterior "${pr.title || pr.id.slice(0, 8)}": este push la reemplaza.`));
|
|
708
|
+
} catch (e) {
|
|
709
|
+
ui.warn(`No se pudo descartar tu propuesta anterior ${pr.id.slice(0, 8)}: ${e.message}`);
|
|
671
710
|
}
|
|
672
711
|
}
|
|
673
|
-
config.persistCredsIfChanged(cfg, mgr.current);
|
|
674
|
-
}
|
|
675
712
|
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
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)]) {
|
|
713
|
+
// 8b) Lotes: hasta PROPOSAL_MAX_CHANGES cambios y PROPOSAL_MAX_BYTES de
|
|
714
|
+
// texto inline por propuesta. Los binarios no suman bytes: van por oid.
|
|
715
|
+
const toSend = [...plan.added, ...plan.modified];
|
|
716
|
+
const batches = [];
|
|
707
717
|
let current = [];
|
|
708
718
|
let currentBytes = 0;
|
|
709
|
-
for (const
|
|
710
|
-
const bytes =
|
|
719
|
+
for (const u of toSend) {
|
|
720
|
+
const bytes = u.binary || u.size > PROPOSAL_INLINE_MAX ? 0 : u.size;
|
|
711
721
|
const full =
|
|
712
|
-
current.length >=
|
|
722
|
+
current.length >= PROPOSAL_MAX_CHANGES || currentBytes + bytes > PROPOSAL_MAX_BYTES;
|
|
713
723
|
if (current.length > 0 && full) {
|
|
714
724
|
batches.push(current);
|
|
715
725
|
current = [];
|
|
716
726
|
currentBytes = 0;
|
|
717
727
|
}
|
|
718
|
-
current.push(
|
|
728
|
+
current.push(u);
|
|
719
729
|
currentBytes += bytes;
|
|
720
730
|
}
|
|
721
731
|
if (current.length > 0) batches.push(current);
|
|
722
|
-
}
|
|
723
732
|
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
733
|
+
const failedWrites = [];
|
|
734
|
+
const uploaded = [];
|
|
735
|
+
const open = [];
|
|
736
|
+
let applied = 0;
|
|
737
|
+
let appliedCount = 0;
|
|
738
|
+
let stale = 0;
|
|
739
|
+
let main = listed.main;
|
|
730
740
|
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
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;
|
|
741
|
+
const upProg = ui.progress(toSend.length, 'subiendo');
|
|
742
|
+
let upDone = 0;
|
|
743
|
+
|
|
744
|
+
for (let bi = 0; bi < batches.length; bi += 1) {
|
|
745
|
+
const entries = batches[bi];
|
|
746
|
+
|
|
747
|
+
// Contenido completo, con reclasificación (ver loadContent).
|
|
748
|
+
const loaded = [];
|
|
749
|
+
for (const u of entries) {
|
|
750
|
+
try {
|
|
751
|
+
await loadContent(u);
|
|
752
|
+
loaded.push(u);
|
|
753
|
+
} catch (err) {
|
|
754
|
+
failedWrites.push({ relPath: u.relPath, reason: err.message });
|
|
755
|
+
logError(upProg, `${u.relPath} — ${err.message}`);
|
|
756
|
+
upDone += 1;
|
|
757
|
+
upProg.update(upDone);
|
|
763
758
|
}
|
|
764
759
|
}
|
|
765
|
-
|
|
766
|
-
});
|
|
767
|
-
}
|
|
760
|
+
if (loaded.length === 0) continue;
|
|
768
761
|
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
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
|
|
762
|
+
// Lo que no puede ir inline se inventaría antes, en paralelo y con el
|
|
763
|
+
// mismo control de ritmo que cualquier subida.
|
|
764
|
+
const byOid = loaded.filter(
|
|
765
|
+
(u) => u.binary || Buffer.byteLength(u.content, 'utf8') > PROPOSAL_INLINE_MAX
|
|
791
766
|
);
|
|
767
|
+
const registered = new Map();
|
|
768
|
+
if (byOid.length > 0) {
|
|
769
|
+
const results = await sync.runPool(byOid, throttle, async (u) => {
|
|
770
|
+
const buffer = Buffer.isBuffer(u.content) ? u.content : Buffer.from(u.content, 'utf8');
|
|
771
|
+
const creds = await mgr.valid();
|
|
772
|
+
const { oid } = await client.registerBlob(
|
|
773
|
+
projectId,
|
|
774
|
+
{ path: u.relPath, buffer, contentHash: pushindex.hashContent(buffer), mimeType: u.mimeType },
|
|
775
|
+
creds
|
|
776
|
+
);
|
|
777
|
+
return oid;
|
|
778
|
+
});
|
|
779
|
+
results.forEach((r, i) => {
|
|
780
|
+
if (r && r.__error) {
|
|
781
|
+
failedWrites.push({ relPath: byOid[i].relPath, reason: r.__error });
|
|
782
|
+
logError(upProg, `${byOid[i].relPath} — ${r.__error}`);
|
|
783
|
+
} else {
|
|
784
|
+
registered.set(byOid[i], r);
|
|
785
|
+
}
|
|
786
|
+
});
|
|
787
|
+
config.persistCredsIfChanged(cfg, mgr.current);
|
|
788
|
+
}
|
|
792
789
|
|
|
793
|
-
const
|
|
794
|
-
const
|
|
795
|
-
const
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
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;
|
|
790
|
+
const sent = [];
|
|
791
|
+
const changes = [];
|
|
792
|
+
for (const u of loaded) {
|
|
793
|
+
if (byOid.includes(u)) {
|
|
794
|
+
if (!registered.has(u)) continue;
|
|
795
|
+
changes.push({ path: u.relPath, oid: registered.get(u) });
|
|
796
|
+
} else {
|
|
797
|
+
changes.push({ path: u.relPath, content: u.content, mime_type: u.mimeType });
|
|
798
|
+
}
|
|
799
|
+
sent.push(u);
|
|
806
800
|
}
|
|
801
|
+
upDone += loaded.length;
|
|
802
|
+
upProg.update(upDone);
|
|
803
|
+
if (changes.length === 0) continue;
|
|
807
804
|
|
|
808
|
-
|
|
809
|
-
|
|
805
|
+
const title = buildMessage(message, changes.length, toSend.length, bi + 1, batches.length, card);
|
|
806
|
+
let result;
|
|
807
|
+
try {
|
|
808
|
+
result = await client.propose(
|
|
809
|
+
projectId,
|
|
810
|
+
{ base, title, summary: '', changes, agent, prompt: promptText, card },
|
|
811
|
+
await mgr.valid()
|
|
812
|
+
);
|
|
813
|
+
for (const w of result.warnings || []) ui.warn(w);
|
|
814
|
+
config.persistCredsIfChanged(cfg, mgr.current);
|
|
815
|
+
} catch (e) {
|
|
816
|
+
// Una base que el servidor no conoce (proyecto recreado, historial
|
|
817
|
+
// podado) no es motivo para no guardar: se reintenta desde la actual.
|
|
818
|
+
if (e.status === 404 && base && base !== main && e.body && /punto de guardado/i.test(e.body.error || '')) {
|
|
819
|
+
ui.warn('El servidor no reconoce la versión de partida anotada: se parte de la actual.');
|
|
820
|
+
base = main;
|
|
821
|
+
bi -= 1;
|
|
822
|
+
for (const u of loaded) u.content = null;
|
|
823
|
+
continue;
|
|
824
|
+
}
|
|
825
|
+
for (const u of sent) failedWrites.push({ relPath: u.relPath, reason: e.message });
|
|
826
|
+
logError(upProg, `Lote ${bi + 1}/${batches.length} — ${e.message}`);
|
|
827
|
+
for (const u of loaded) u.content = null;
|
|
828
|
+
continue;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
if (result.main) main = result.main;
|
|
832
|
+
for (const u of sent) uploaded.push({ ...u, hash: pushindex.hashContent(u.content) });
|
|
810
833
|
|
|
811
|
-
|
|
834
|
+
if (result.empty) {
|
|
835
|
+
stale += sent.length;
|
|
836
|
+
} else if (result.applied) {
|
|
837
|
+
applied += sent.length;
|
|
838
|
+
appliedCount += 1;
|
|
839
|
+
for (const u of sent) {
|
|
840
|
+
logFile(upProg, `${u.remote ? ui.c.yellow('~') : ui.c.green('+')} ${u.relPath}`);
|
|
841
|
+
}
|
|
842
|
+
} else {
|
|
843
|
+
open.push({ ...result, paths: sent.map((u) => u.relPath) });
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
for (const u of loaded) u.content = null;
|
|
847
|
+
}
|
|
848
|
+
upProg.clear();
|
|
849
|
+
|
|
850
|
+
return { failedWrites, committed: applied, commitCount: appliedCount, uploaded, open, stale, main, base };
|
|
812
851
|
}
|
|
813
852
|
|
|
814
|
-
/**
|
|
815
|
-
|
|
816
|
-
|
|
853
|
+
/**
|
|
854
|
+
* El camino anterior a la Etapa 3: escribe archivo por archivo (o por lote) y
|
|
855
|
+
* después registra el commit. Puede pisar cambios ajenos, porque el servidor
|
|
856
|
+
* viejo no tiene con qué compararlos. Queda hasta que ningún servidor lo
|
|
857
|
+
* necesite.
|
|
858
|
+
*/
|
|
859
|
+
async function pushLegacy() {
|
|
860
|
+
const failedWrites = [];
|
|
861
|
+
let committed = 0;
|
|
862
|
+
let commitCount = 0;
|
|
863
|
+
|
|
864
|
+
// 8a) Carpetas faltantes, por nivel. El árbol remoto ya nos dice cuáles
|
|
865
|
+
// existen, así que no hace falta listar antes de crear cada una; y crear
|
|
866
|
+
// nivel por nivel (paralelo dentro del nivel, serie entre niveles)
|
|
867
|
+
// garantiza que el padre exista sin depender del 409 del backend, que no
|
|
868
|
+
// es confiable ante dos creaciones simultáneas de la misma carpeta.
|
|
869
|
+
//
|
|
870
|
+
// Se ejecuta a demanda: el endpoint de lote hace mkdir -p por su cuenta,
|
|
871
|
+
// así que estos requests sólo hacen falta en el camino archivo-por-archivo.
|
|
872
|
+
const failedFolders = [];
|
|
873
|
+
let foldersEnsured = false;
|
|
874
|
+
|
|
875
|
+
async function ensureFolders() {
|
|
876
|
+
if (foldersEnsured) return;
|
|
877
|
+
foldersEnsured = true;
|
|
878
|
+
if (plan.added.length === 0) return;
|
|
879
|
+
|
|
880
|
+
if (treeComplete) {
|
|
881
|
+
const missing = [...neededFolders(plan.added.map((u) => u.relPath))].filter(
|
|
882
|
+
(f) => !tree.folders.has(f)
|
|
883
|
+
);
|
|
884
|
+
const byDepth = new Map();
|
|
885
|
+
for (const f of missing) {
|
|
886
|
+
const d = f.split('/').length;
|
|
887
|
+
if (!byDepth.has(d)) byDepth.set(d, []);
|
|
888
|
+
byDepth.get(d).push(f);
|
|
889
|
+
}
|
|
817
890
|
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
891
|
+
for (const depth of [...byDepth.keys()].sort((a, b) => a - b)) {
|
|
892
|
+
const level = byDepth.get(depth);
|
|
893
|
+
const results = await sync.runPool(level, throttle, async (folderPath) => {
|
|
894
|
+
const parts = folderPath.split('/');
|
|
895
|
+
const name = parts.pop();
|
|
896
|
+
const creds = await mgr.valid();
|
|
897
|
+
await client.createFolder(projectId, name, parts.join('/'), creds);
|
|
898
|
+
return folderPath;
|
|
899
|
+
});
|
|
900
|
+
for (const r of results) {
|
|
901
|
+
if (r && r.__error) {
|
|
902
|
+
failedFolders.push({ folderPath: r.item, reason: r.__error });
|
|
903
|
+
ui.error(`carpeta ${r.item} — ${r.__error}`);
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
}
|
|
823
907
|
} else {
|
|
824
|
-
|
|
908
|
+
// Árbol incompleto: no sabemos qué existe. Volvemos al camino conservador.
|
|
909
|
+
const ensuredCache = new Set();
|
|
910
|
+
for (const u of plan.added) {
|
|
911
|
+
try {
|
|
912
|
+
const creds = await mgr.valid();
|
|
913
|
+
await client.ensurePath(projectId, u.relPath, creds, ensuredCache);
|
|
914
|
+
} catch (e) {
|
|
915
|
+
failedFolders.push({ folderPath: path.posix.dirname(u.relPath), reason: e.message });
|
|
916
|
+
ui.error(`${u.relPath} — ${e.message}`);
|
|
917
|
+
}
|
|
918
|
+
}
|
|
825
919
|
}
|
|
826
|
-
|
|
920
|
+
config.persistCredsIfChanged(cfg, mgr.current);
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
const blocked = (relPath) =>
|
|
924
|
+
failedFolders.some((f) => f.folderPath && relPath.startsWith(`${f.folderPath}/`));
|
|
925
|
+
|
|
926
|
+
// 8b) Subidas. El costo dominante no es el trabajo del servidor sino la
|
|
927
|
+
// latencia de cada round-trip, así que van agrupadas: el endpoint de lote
|
|
928
|
+
// escribe hasta 100 archivos por request y crea las carpetas que falten.
|
|
929
|
+
// El contenido se lee lote por lote, no todo de una: un repo grande no
|
|
930
|
+
// tiene por qué entrar entero en memoria.
|
|
931
|
+
const BATCH_MAX_FILES = COMMIT_BATCH; // 100: mismo techo que el commit
|
|
932
|
+
const BATCH_MAX_BYTES = 4 * 1024 * 1024; // el server corta en 5 MB; margen
|
|
933
|
+
|
|
934
|
+
const toUpload = [
|
|
935
|
+
...plan.added.map((u) => ({ u, isNew: true })),
|
|
936
|
+
...plan.modified.map((u) => ({ u, isNew: false })),
|
|
937
|
+
];
|
|
938
|
+
|
|
939
|
+
// Empaquetado por cantidad Y por tamaño. Un archivo más grande que el
|
|
940
|
+
// presupuesto termina solo en su lote, y ahí se sube por el endpoint
|
|
941
|
+
// individual.
|
|
942
|
+
//
|
|
943
|
+
// Los lotes son HOMOGÉNEOS: o todo texto, o todo binario. Los dos grupos se
|
|
944
|
+
// escriben por caminos distintos —lote JSON contra URL firmada— y un lote
|
|
945
|
+
// mezclado obligaría a partirlo en el momento de escribir, justo donde después
|
|
946
|
+
// hay que volver a juntar los cambios para un solo commit. Separarlos acá
|
|
947
|
+
// deja esa parte sin ramas.
|
|
948
|
+
//
|
|
949
|
+
// El presupuesto de bytes no se le aplica a los binarios: su contenido nunca
|
|
950
|
+
// entra en el payload de un request, así que un lote de diez PDF de 30 MB son
|
|
951
|
+
// diez subidas directas y ningún JSON grande.
|
|
952
|
+
const batches = [];
|
|
953
|
+
for (const group of [toUpload.filter((e) => !e.u.binary), toUpload.filter((e) => e.u.binary)]) {
|
|
954
|
+
let current = [];
|
|
955
|
+
let currentBytes = 0;
|
|
956
|
+
for (const e of group) {
|
|
957
|
+
const bytes = e.u.binary ? 0 : typeof e.u.size === 'number' ? e.u.size : 0;
|
|
958
|
+
const full =
|
|
959
|
+
current.length >= BATCH_MAX_FILES || currentBytes + bytes > BATCH_MAX_BYTES;
|
|
960
|
+
if (current.length > 0 && full) {
|
|
961
|
+
batches.push(current);
|
|
962
|
+
current = [];
|
|
963
|
+
currentBytes = 0;
|
|
964
|
+
}
|
|
965
|
+
current.push(e);
|
|
966
|
+
currentBytes += bytes;
|
|
967
|
+
}
|
|
968
|
+
if (current.length > 0) batches.push(current);
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
const upProg = ui.progress(toUpload.length, 'subiendo');
|
|
972
|
+
let upDone = 0;
|
|
973
|
+
const tickUp = (n = 1) => {
|
|
974
|
+
upDone += n;
|
|
975
|
+
upProg.update(upDone);
|
|
976
|
+
};
|
|
977
|
+
|
|
978
|
+
const uploaded = []; // { relPath, size, mtimeMs, hash } para el índice
|
|
979
|
+
let batchSupported = true; // se descubre en el primer lote
|
|
980
|
+
|
|
981
|
+
/** Escribe un lote entero en un request. Devuelve un resultado por archivo. */
|
|
982
|
+
async function writeViaBatch(entries) {
|
|
983
|
+
const payload = entries.map((e) => ({
|
|
984
|
+
path: e.u.relPath,
|
|
985
|
+
content: e.content,
|
|
986
|
+
mimeType: e.u.mimeType,
|
|
987
|
+
}));
|
|
988
|
+
const creds = await mgr.valid();
|
|
989
|
+
const { results } = await client.writeBatch(projectId, payload, creds);
|
|
990
|
+
|
|
991
|
+
return entries.map((e, i) => {
|
|
992
|
+
const r = results[i];
|
|
993
|
+
if (!r || !r.ok) {
|
|
994
|
+
return { ok: false, reason: (r && r.error) || 'el servidor no devolvió resultado' };
|
|
995
|
+
}
|
|
996
|
+
const f = r.file || {};
|
|
997
|
+
const action = r.action === 'created' ? 'added' : 'modified';
|
|
998
|
+
const change = { file_id: f.id, action, new_storage_path: f.storage_path };
|
|
999
|
+
if (action === 'modified') {
|
|
1000
|
+
// El backend devuelve el storage_path que acaba de reemplazar; sólo si
|
|
1001
|
+
// faltara caemos a lo que sabíamos del árbol remoto.
|
|
1002
|
+
change.previous_storage_path =
|
|
1003
|
+
f.previous_storage_path || (e.u.remote && e.u.remote.storage_path) || f.storage_path;
|
|
1004
|
+
// El hash del contenido que estamos reemplazando lo sabe únicamente el
|
|
1005
|
+
// cliente: cuando el commit llega al servidor, `files.content_hash` ya
|
|
1006
|
+
// fue pisado con el contenido nuevo. Puede ser null en archivos
|
|
1007
|
+
// anteriores a la migración 010.
|
|
1008
|
+
if (e.u.remote && e.u.remote.content_hash) {
|
|
1009
|
+
change.previous_content_hash = e.u.remote.content_hash;
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
return { ok: true, change, symbol: action === 'added' ? '+' : '~' };
|
|
1013
|
+
});
|
|
1014
|
+
}
|
|
827
1015
|
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
1016
|
+
/**
|
|
1017
|
+
* Camino de los binarios: URL firmada, un archivo a la vez y en paralelo.
|
|
1018
|
+
*
|
|
1019
|
+
* No se agrupan en un request como el texto porque no hay nada que agrupar:
|
|
1020
|
+
* cada byte va directo a Storage. Lo que sí se comparte es el control de
|
|
1021
|
+
* ritmo, que es el que evita que veinte subidas de 30 MB salgan juntas.
|
|
1022
|
+
*
|
|
1023
|
+
* El endpoint crea las carpetas intermedias por su cuenta, igual que el lote,
|
|
1024
|
+
* así que acá no hace falta `ensureFolders()`.
|
|
1025
|
+
*/
|
|
1026
|
+
async function writeViaDirect(entries) {
|
|
1027
|
+
const results = await sync.runPool(entries, throttle, async (e) => {
|
|
831
1028
|
const creds = await mgr.valid();
|
|
832
|
-
const
|
|
833
|
-
|
|
1029
|
+
const { result } = await client.uploadBinary(
|
|
1030
|
+
projectId,
|
|
1031
|
+
{
|
|
1032
|
+
path: e.u.relPath,
|
|
1033
|
+
buffer: e.content,
|
|
1034
|
+
contentHash: pushindex.hashContent(e.content),
|
|
1035
|
+
mimeType: e.u.mimeType,
|
|
1036
|
+
},
|
|
1037
|
+
creds
|
|
1038
|
+
);
|
|
1039
|
+
|
|
1040
|
+
const f = (result && result.file) || {};
|
|
1041
|
+
const action = result && result.action === 'created' ? 'added' : 'modified';
|
|
1042
|
+
const change = { file_id: f.id, action, new_storage_path: f.storage_path };
|
|
1043
|
+
|
|
1044
|
+
if (action === 'modified') {
|
|
1045
|
+
change.previous_storage_path =
|
|
1046
|
+
f.previous_storage_path || (e.u.remote && e.u.remote.storage_path) || f.storage_path;
|
|
1047
|
+
// El hash de lo que estamos reemplazando: el servidor lo lee antes de
|
|
1048
|
+
// pisar la fila, que es el único momento en que existe. Si no vino, lo
|
|
1049
|
+
// sabemos por el árbol remoto.
|
|
1050
|
+
const previous =
|
|
1051
|
+
f.previous_content_hash || (e.u.remote && e.u.remote.content_hash) || null;
|
|
1052
|
+
if (previous) change.previous_content_hash = previous;
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
return { ok: true, change, symbol: action === 'added' ? '+' : '~' };
|
|
1056
|
+
});
|
|
1057
|
+
|
|
1058
|
+
return results.map((r) => (r && r.__error ? { ok: false, reason: r.__error } : r));
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
/** Camino archivo-por-archivo: server sin /batch, o archivo demasiado grande. */
|
|
1062
|
+
async function writeViaSingle(entries) {
|
|
1063
|
+
await ensureFolders();
|
|
1064
|
+
|
|
1065
|
+
const outcomes = new Array(entries.length);
|
|
1066
|
+
const queue = [];
|
|
1067
|
+
entries.forEach((e, i) => {
|
|
1068
|
+
if (e.isNew && blocked(e.u.relPath)) {
|
|
1069
|
+
outcomes[i] = { ok: false, reason: 'no se pudo crear su carpeta' };
|
|
1070
|
+
} else {
|
|
1071
|
+
queue.push({ e, i });
|
|
1072
|
+
}
|
|
1073
|
+
});
|
|
1074
|
+
|
|
1075
|
+
const results = await sync.runPool(queue, throttle, async ({ e }) => {
|
|
1076
|
+
const u = e.u;
|
|
1077
|
+
if (e.isNew) {
|
|
1078
|
+
const creds = await mgr.valid();
|
|
1079
|
+
const created = await client.createFile(projectId, u.relPath, e.content, u.mimeType, creds);
|
|
1080
|
+
if (created.created && created.file) {
|
|
1081
|
+
return {
|
|
1082
|
+
ok: true,
|
|
1083
|
+
symbol: '+',
|
|
1084
|
+
change: {
|
|
1085
|
+
file_id: created.file.id,
|
|
1086
|
+
action: 'added',
|
|
1087
|
+
new_storage_path: created.file.storage_path,
|
|
1088
|
+
},
|
|
1089
|
+
};
|
|
1090
|
+
}
|
|
1091
|
+
// 409: apareció entremedio; caemos a update.
|
|
1092
|
+
const writeCreds = await mgr.valid();
|
|
1093
|
+
const upd = await client.writeFile(projectId, u.relPath, e.content, u.mimeType, writeCreds);
|
|
834
1094
|
return {
|
|
835
1095
|
ok: true,
|
|
836
|
-
symbol: '
|
|
1096
|
+
symbol: '~',
|
|
837
1097
|
change: {
|
|
838
|
-
file_id:
|
|
839
|
-
action: '
|
|
840
|
-
|
|
1098
|
+
file_id: upd.file.id,
|
|
1099
|
+
action: 'modified',
|
|
1100
|
+
previous_storage_path:
|
|
1101
|
+
(created.file && created.file.storage_path) || upd.file.storage_path,
|
|
1102
|
+
new_storage_path: upd.file.storage_path,
|
|
841
1103
|
},
|
|
842
1104
|
};
|
|
843
1105
|
}
|
|
844
|
-
|
|
845
|
-
const
|
|
846
|
-
const upd = await client.writeFile(projectId, u.relPath, e.content, u.mimeType,
|
|
1106
|
+
|
|
1107
|
+
const creds = await mgr.valid();
|
|
1108
|
+
const upd = await client.writeFile(projectId, u.relPath, e.content, u.mimeType, creds);
|
|
847
1109
|
return {
|
|
848
1110
|
ok: true,
|
|
849
1111
|
symbol: '~',
|
|
850
1112
|
change: {
|
|
851
1113
|
file_id: upd.file.id,
|
|
852
1114
|
action: 'modified',
|
|
853
|
-
previous_storage_path:
|
|
854
|
-
(created.file && created.file.storage_path) || upd.file.storage_path,
|
|
1115
|
+
previous_storage_path: u.remote ? u.remote.storage_path : upd.file.storage_path,
|
|
855
1116
|
new_storage_path: upd.file.storage_path,
|
|
1117
|
+
// Ver la nota en writeViaBatch: el hash previo sólo existe acá.
|
|
1118
|
+
...(u.remote && u.remote.content_hash
|
|
1119
|
+
? { previous_content_hash: u.remote.content_hash }
|
|
1120
|
+
: {}),
|
|
856
1121
|
},
|
|
857
1122
|
};
|
|
858
|
-
}
|
|
859
|
-
|
|
860
|
-
const creds = await mgr.valid();
|
|
861
|
-
const upd = await client.writeFile(projectId, u.relPath, e.content, u.mimeType, creds);
|
|
862
|
-
return {
|
|
863
|
-
ok: true,
|
|
864
|
-
symbol: '~',
|
|
865
|
-
change: {
|
|
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
|
-
: {}),
|
|
874
|
-
},
|
|
875
|
-
};
|
|
876
|
-
});
|
|
877
|
-
|
|
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;
|
|
883
|
-
}
|
|
884
|
-
return outcomes;
|
|
885
|
-
}
|
|
886
|
-
|
|
887
|
-
for (let bi = 0; bi < batches.length; bi += 1) {
|
|
888
|
-
const entries = batches[bi];
|
|
1123
|
+
});
|
|
889
1124
|
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
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();
|
|
1125
|
+
// `runPool` devuelve los resultados alineados con la entrada, así que el
|
|
1126
|
+
// índice de `queue` alcanza para volver a la posición original.
|
|
1127
|
+
for (let k = 0; k < results.length; k += 1) {
|
|
1128
|
+
const r = results[k];
|
|
1129
|
+
outcomes[queue[k].i] = r && r.__error ? { ok: false, reason: r.__error } : r;
|
|
901
1130
|
}
|
|
902
|
-
|
|
903
|
-
if (loaded.length === 0) continue;
|
|
904
|
-
|
|
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]));
|
|
1131
|
+
return outcomes;
|
|
920
1132
|
}
|
|
921
1133
|
|
|
922
|
-
|
|
923
|
-
const
|
|
924
|
-
texts.length === 1 && Buffer.byteLength(texts[0].content, 'utf8') > BATCH_MAX_BYTES;
|
|
925
|
-
let res = null;
|
|
1134
|
+
for (let bi = 0; bi < batches.length; bi += 1) {
|
|
1135
|
+
const entries = batches[bi];
|
|
926
1136
|
|
|
927
|
-
|
|
1137
|
+
// Un fallo de lectura (dejó de ser texto, permisos) saca a ese archivo del
|
|
1138
|
+
// lote pero no arrastra a los demás.
|
|
1139
|
+
const loaded = [];
|
|
1140
|
+
for (const e of entries) {
|
|
928
1141
|
try {
|
|
929
|
-
|
|
1142
|
+
e.content = await loadContent(e.u);
|
|
1143
|
+
loaded.push(e);
|
|
930
1144
|
} catch (err) {
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
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
|
-
}
|
|
1145
|
+
failedWrites.push({ relPath: e.u.relPath, reason: err.message });
|
|
1146
|
+
logError(upProg, `${e.u.relPath} — ${err.message}`);
|
|
1147
|
+
tickUp();
|
|
941
1148
|
}
|
|
942
1149
|
}
|
|
1150
|
+
if (loaded.length === 0) continue;
|
|
1151
|
+
|
|
1152
|
+
// El lote se armó con lo que dijo el preflight, que sólo miró los primeros
|
|
1153
|
+
// 8 KB. `loadContent` acaba de leer el archivo entero y pudo reclasificarlo,
|
|
1154
|
+
// así que la partición se rehace ACÁ, sobre el contenido de verdad. Mandar
|
|
1155
|
+
// un Buffer por el lote JSON lo convertiría en `{"type":"Buffer",...}`, que
|
|
1156
|
+
// es la corrupción silenciosa que toda esta etapa existe para evitar.
|
|
1157
|
+
const binaries = loaded.filter((e) => e.u.binary);
|
|
1158
|
+
const texts = loaded.filter((e) => !e.u.binary);
|
|
1159
|
+
|
|
1160
|
+
const outcomeByEntry = new Map();
|
|
1161
|
+
|
|
1162
|
+
if (binaries.length > 0) {
|
|
1163
|
+
// Un fallo acá es por archivo, no del lote: `writeViaDirect` devuelve un
|
|
1164
|
+
// resultado por entrada y los errores ya vienen adentro.
|
|
1165
|
+
const res = await writeViaDirect(binaries);
|
|
1166
|
+
binaries.forEach((e, i) => outcomeByEntry.set(e, res[i]));
|
|
1167
|
+
}
|
|
943
1168
|
|
|
944
|
-
if (
|
|
945
|
-
|
|
946
|
-
|
|
1169
|
+
if (texts.length > 0) {
|
|
1170
|
+
const oversize =
|
|
1171
|
+
texts.length === 1 && Buffer.byteLength(texts[0].content, 'utf8') > BATCH_MAX_BYTES;
|
|
1172
|
+
let res = null;
|
|
1173
|
+
|
|
1174
|
+
if (batchSupported && !oversize) {
|
|
1175
|
+
try {
|
|
1176
|
+
res = await writeViaBatch(texts);
|
|
1177
|
+
} catch (err) {
|
|
1178
|
+
if (err.notSupported) {
|
|
1179
|
+
batchSupported = false;
|
|
1180
|
+
upProg.clear();
|
|
1181
|
+
ui.warn('El servidor no acepta escritura por lotes; se sube archivo por archivo.');
|
|
1182
|
+
} else {
|
|
1183
|
+
// Falla del request entero (red, 5xx, permisos): ningún archivo de
|
|
1184
|
+
// texto del lote se escribió. Los binarios ya se resolvieron aparte
|
|
1185
|
+
// y no los arrastra.
|
|
1186
|
+
res = texts.map(() => ({ ok: false, reason: err.message }));
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
947
1190
|
|
|
948
|
-
|
|
949
|
-
|
|
1191
|
+
if (!res) res = await writeViaSingle(texts);
|
|
1192
|
+
texts.forEach((e, i) => outcomeByEntry.set(e, res[i]));
|
|
1193
|
+
}
|
|
950
1194
|
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
const
|
|
955
|
-
|
|
956
|
-
const
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
1195
|
+
const outcomes = loaded.map((e) => outcomeByEntry.get(e));
|
|
1196
|
+
config.persistCredsIfChanged(cfg, mgr.current);
|
|
1197
|
+
|
|
1198
|
+
const batchChanges = [];
|
|
1199
|
+
for (let i = 0; i < loaded.length; i += 1) {
|
|
1200
|
+
const e = loaded[i];
|
|
1201
|
+
const o = outcomes[i];
|
|
1202
|
+
if (!o || !o.ok) {
|
|
1203
|
+
const reason = (o && o.reason) || 'error desconocido';
|
|
1204
|
+
failedWrites.push({ relPath: e.u.relPath, reason });
|
|
1205
|
+
logError(upProg, `${e.u.relPath} — ${reason}`);
|
|
1206
|
+
continue;
|
|
1207
|
+
}
|
|
1208
|
+
batchChanges.push(o.change);
|
|
1209
|
+
uploaded.push({ ...e.u, hash: pushindex.hashContent(e.content) });
|
|
1210
|
+
logFile(upProg, `${ui.c[o.symbol === '+' ? 'green' : 'yellow'](o.symbol)} ${e.u.relPath}`);
|
|
960
1211
|
}
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
} catch (e) {
|
|
988
|
-
ui.error(`Fallo al crear el commit (lote ${partNo}/${batches.length}): ${e.message}`);
|
|
989
|
-
failedWrites.push({ relPath: `<commit lote ${partNo}>`, reason: e.message });
|
|
1212
|
+
tickUp(loaded.length);
|
|
1213
|
+
|
|
1214
|
+
// 9) Commit del lote, apenas termina de escribirse. Juntar todos los cambios
|
|
1215
|
+
// para un commit final ahorraba requests, pero dejaba una ventana larga
|
|
1216
|
+
// en la que los archivos ya estaban escritos y ningún commit los
|
|
1217
|
+
// mencionaba; si el push moría ahí, el historial quedaba mintiendo.
|
|
1218
|
+
if (batchChanges.length > 0) {
|
|
1219
|
+
const partNo = bi + 1;
|
|
1220
|
+
const msg = buildMessage(
|
|
1221
|
+
message,
|
|
1222
|
+
batchChanges.length,
|
|
1223
|
+
toUpload.length,
|
|
1224
|
+
partNo,
|
|
1225
|
+
batches.length,
|
|
1226
|
+
card
|
|
1227
|
+
);
|
|
1228
|
+
try {
|
|
1229
|
+
const creds = await mgr.valid();
|
|
1230
|
+
await client.createCommit(projectId, msg, batchChanges, creds, { card });
|
|
1231
|
+
committed += batchChanges.length;
|
|
1232
|
+
commitCount += 1;
|
|
1233
|
+
config.persistCredsIfChanged(cfg, mgr.current);
|
|
1234
|
+
} catch (e) {
|
|
1235
|
+
ui.error(`Fallo al crear el commit (lote ${partNo}/${batches.length}): ${e.message}`);
|
|
1236
|
+
failedWrites.push({ relPath: `<commit lote ${partNo}>`, reason: e.message });
|
|
1237
|
+
}
|
|
990
1238
|
}
|
|
991
|
-
}
|
|
992
1239
|
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
1240
|
+
// El contenido ya se hasheó y se subió: soltarlo mantiene el pico de memoria
|
|
1241
|
+
// atado al tamaño del lote y no al del repo.
|
|
1242
|
+
for (const e of loaded) {
|
|
1243
|
+
e.content = null;
|
|
1244
|
+
e.u.content = null;
|
|
1245
|
+
}
|
|
998
1246
|
}
|
|
1247
|
+
upProg.clear();
|
|
1248
|
+
|
|
1249
|
+
config.persistCredsIfChanged(cfg, mgr.current);
|
|
1250
|
+
return { failedWrites, committed, commitCount, uploaded, open: [], stale: 0, main: null, base: null };
|
|
999
1251
|
}
|
|
1000
|
-
|
|
1252
|
+
|
|
1253
|
+
let outcome = await pushViaProposals();
|
|
1254
|
+
const legacy = outcome === null;
|
|
1255
|
+
if (legacy) {
|
|
1256
|
+
ui.warn(
|
|
1257
|
+
'Este servidor todavía no recibe propuestas: se sube por el camino anterior, ' +
|
|
1258
|
+
'que puede pisar cambios de otras personas.'
|
|
1259
|
+
);
|
|
1260
|
+
outcome = await pushLegacy();
|
|
1261
|
+
}
|
|
1262
|
+
const { failedWrites, committed, commitCount, uploaded, open, stale, main } = outcome;
|
|
1001
1263
|
|
|
1002
1264
|
config.persistCredsIfChanged(cfg, mgr.current);
|
|
1003
1265
|
|
|
1004
1266
|
// 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.
|
|
1267
|
+
// push arranca sabiendo qué no hace falta ni leer. Lo que quedó en una
|
|
1268
|
+
// propuesta abierta también se anota: su hash no coincide con el de la
|
|
1269
|
+
// versión actual, así que el push siguiente lo vuelve a mirar igual.
|
|
1006
1270
|
if (useIndex) {
|
|
1007
1271
|
for (const u of uploaded) {
|
|
1008
1272
|
pushindex.record(index, u, pushedAt);
|
|
1009
1273
|
}
|
|
1010
|
-
saveIndex(dir, index, plan,
|
|
1274
|
+
saveIndex(dir, index, plan, allUploads, pushedAt);
|
|
1011
1275
|
}
|
|
1012
1276
|
|
|
1013
|
-
// 11) Anotar la sincronización
|
|
1014
|
-
//
|
|
1015
|
-
//
|
|
1016
|
-
|
|
1017
|
-
|
|
1277
|
+
// 11) Anotar la sincronización y la base nueva. Sólo si TODO quedó en la
|
|
1278
|
+
// versión actual: con archivos fallados o propuestas abiertas, la carpeta
|
|
1279
|
+
// no está alineada, y mover la base haría que el push siguiente proponga
|
|
1280
|
+
// esos mismos archivos como si nadie más los hubiera tocado.
|
|
1281
|
+
const clean = failedWrites.length === 0 && plan.failed.length === 0 && open.length === 0;
|
|
1282
|
+
if (clean) {
|
|
1283
|
+
remote.touchSync(dir, { projectId, baseURL, base: legacy ? null : main });
|
|
1018
1284
|
}
|
|
1019
1285
|
|
|
1020
1286
|
// 12) Resumen final.
|
|
1021
1287
|
ui.info('');
|
|
1022
|
-
|
|
1023
|
-
|
|
1288
|
+
reportLeftOut(leftOut, remoteByPath);
|
|
1289
|
+
for (const o of open) reportOpen(o);
|
|
1290
|
+
if (stale > 0) {
|
|
1291
|
+
ui.warn(
|
|
1292
|
+
`${stale} archivo(s) ya no cambian nada: la versión actual avanzó y lo que tenés ` +
|
|
1293
|
+
'es lo de antes. Traé lo nuevo con "shilo pull" antes de volver a subir.'
|
|
1294
|
+
);
|
|
1295
|
+
}
|
|
1296
|
+
if (clean) {
|
|
1297
|
+
if (committed === 0 && stale === 0) {
|
|
1298
|
+
ui.success('Todo al día. Nada para subir.');
|
|
1299
|
+
} else if (legacy) {
|
|
1300
|
+
ui.success(`Push completo: ${committed} archivo(s) en ${commitCount} commit(s).`);
|
|
1301
|
+
} else {
|
|
1302
|
+
ui.success(`Push completo: ${committed} archivo(s) ya son parte de la versión actual.`);
|
|
1303
|
+
}
|
|
1024
1304
|
return 0;
|
|
1025
1305
|
}
|
|
1026
1306
|
for (const f of plan.failed) ui.error(`${f.relPath} — ${f.reason}`);
|
|
1027
|
-
|
|
1028
|
-
`Push con problemas: ${failedWrites.length + plan.failed.length} archivo(s)
|
|
1029
|
-
|
|
1307
|
+
if (failedWrites.length + plan.failed.length > 0) {
|
|
1308
|
+
ui.warn(`Push con problemas: ${failedWrites.length + plan.failed.length} archivo(s) fallaron.`);
|
|
1309
|
+
} else {
|
|
1310
|
+
ui.warn(
|
|
1311
|
+
`${committed} archivo(s) subieron; ${open.reduce((n, o) => n + o.paths.length, 0)} quedaron en ` +
|
|
1312
|
+
`${open.length} propuesta(s) abierta(s).`
|
|
1313
|
+
);
|
|
1314
|
+
}
|
|
1030
1315
|
return 1;
|
|
1031
1316
|
}
|
|
1032
1317
|
|
|
1318
|
+
/**
|
|
1319
|
+
* Cuenta por qué una propuesta quedó abierta, en el vocabulario del glosario:
|
|
1320
|
+
* nunca "merge" ni "conflicto de rama", siempre qué archivo y quién.
|
|
1321
|
+
*/
|
|
1322
|
+
function reportOpen(o) {
|
|
1323
|
+
const id = o.proposal ? o.proposal.id.slice(0, 8) : '?';
|
|
1324
|
+
if (o.conflicts.length > 0) {
|
|
1325
|
+
ui.warn(
|
|
1326
|
+
`${o.conflicts.length} archivo(s) los cambió otra persona desde tu última sincronización, ` +
|
|
1327
|
+
'y vos también:'
|
|
1328
|
+
);
|
|
1329
|
+
for (const c of o.conflicts) {
|
|
1330
|
+
const why =
|
|
1331
|
+
c.kind === 'archivo-y-carpeta'
|
|
1332
|
+
? 'es archivo de un lado y carpeta del otro'
|
|
1333
|
+
: c.ours === null
|
|
1334
|
+
? 'vos lo borraste y en la versión actual cambió'
|
|
1335
|
+
: c.theirs === null
|
|
1336
|
+
? 'lo borraron en la versión actual y vos lo cambiaste'
|
|
1337
|
+
: 'las dos versiones son distintas';
|
|
1338
|
+
ui.info(` ${ui.c.red('!')} ${c.path} ${ui.c.dim(`— ${why}`)}`);
|
|
1339
|
+
}
|
|
1340
|
+
} else if (o.blockedBy.length > 0) {
|
|
1341
|
+
ui.warn('Otra propuesta abierta toca los mismos archivos:');
|
|
1342
|
+
for (const b of o.blockedBy) {
|
|
1343
|
+
ui.info(` ${ui.c.yellow('·')} "${b.title || b.id.slice(0, 8)}" ${ui.c.dim(`— ${b.paths.join(', ')}`)}`);
|
|
1344
|
+
}
|
|
1345
|
+
} else {
|
|
1346
|
+
ui.warn(o.message || 'La propuesta quedó abierta.');
|
|
1347
|
+
}
|
|
1348
|
+
ui.info(
|
|
1349
|
+
` Tus ${o.paths.length} archivo(s) no se perdieron: quedaron como propuesta ${ui.c.bold(id)}, ` +
|
|
1350
|
+
'abierta para que el dueño del proyecto decida.'
|
|
1351
|
+
);
|
|
1352
|
+
ui.info(
|
|
1353
|
+
ui.c.dim(' Para resolverlo vos: "shilo pull" trae la versión actual (no pisa lo tuyo sin --force), ') +
|
|
1354
|
+
ui.c.dim('ajustá y volvé a "shilo push".')
|
|
1355
|
+
);
|
|
1356
|
+
ui.info('');
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1033
1359
|
/**
|
|
1034
1360
|
* Lee el contenido completo de un archivo, cacheado en la propia entrada.
|
|
1035
1361
|
*
|
|
@@ -1062,26 +1388,154 @@ async function loadContent(u) {
|
|
|
1062
1388
|
return u.content;
|
|
1063
1389
|
}
|
|
1064
1390
|
|
|
1391
|
+
/**
|
|
1392
|
+
* Lo que `--only` dejó afuera. "Una pieza = una tarjeta" sólo funciona si la
|
|
1393
|
+
* CLI te recuerda lo que quedó atrás.
|
|
1394
|
+
*
|
|
1395
|
+
* "Cambiado" se decide contra el ÁRBOL REMOTO —falta allá, o está con otro
|
|
1396
|
+
* tamaño—, que es el mismo criterio de `shilo status` y no cuesta un request
|
|
1397
|
+
* extra: el árbol ya se recorrió para planificar. El índice local no sirve acá:
|
|
1398
|
+
* `pull` no lo escribe, así que en una carpeta que se bajó de nuevo queda viejo
|
|
1399
|
+
* y daría por cambiado medio repo.
|
|
1400
|
+
*
|
|
1401
|
+
* Misma concesión que `status`: dos archivos del mismo tamaño se dan por
|
|
1402
|
+
* iguales aunque difieran byte a byte.
|
|
1403
|
+
*/
|
|
1404
|
+
function reportLeftOut(leftOut, remoteByPath) {
|
|
1405
|
+
if (!leftOut || leftOut.length === 0) return;
|
|
1406
|
+
|
|
1407
|
+
const changed = leftOut.filter((u) => {
|
|
1408
|
+
const r = remoteByPath.get(u.relPath);
|
|
1409
|
+
if (!r) return true; // nunca subido
|
|
1410
|
+
return typeof r.size === 'number' && r.size !== u.size;
|
|
1411
|
+
});
|
|
1412
|
+
if (changed.length === 0) {
|
|
1413
|
+
ui.info(
|
|
1414
|
+
ui.c.dim(
|
|
1415
|
+
`${leftOut.length} archivo(s) quedaron fuera de este push (--only); ninguno tiene cambios sin subir.`
|
|
1416
|
+
)
|
|
1417
|
+
);
|
|
1418
|
+
return;
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
ui.warn(`${changed.length} archivo(s) con cambios locales quedaron fuera de este push (--only):`);
|
|
1422
|
+
for (const u of changed.slice(0, SKIP_EXAMPLES)) ui.info(` ${ui.c.dim(u.relPath)}`);
|
|
1423
|
+
if (changed.length > SKIP_EXAMPLES) {
|
|
1424
|
+
ui.info(ui.c.dim(` … y ${changed.length - SKIP_EXAMPLES} más`));
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1065
1428
|
/**
|
|
1066
1429
|
* Registra en el índice los archivos que ya estaban idénticos (para no volver a
|
|
1067
1430
|
* compararlos), saca los que ya no existen y lo escribe.
|
|
1431
|
+
*
|
|
1432
|
+
* `presentUploads` es lo que EXISTE en la carpeta, no lo que se subió en esta
|
|
1433
|
+
* corrida: con `--only` son cosas distintas. Podar con la selección borraría del
|
|
1434
|
+
* índice las entradas del resto del repo, y el push completo siguiente tendría
|
|
1435
|
+
* que recompararlo todo — lento, no incorrecto, pero evitable. Las entradas
|
|
1436
|
+
* nuevas se fusionan sobre el índice ya leído de disco, no lo reemplazan.
|
|
1068
1437
|
*/
|
|
1069
|
-
function saveIndex(dir, index, plan,
|
|
1438
|
+
function saveIndex(dir, index, plan, presentUploads, pushedAt = Date.now()) {
|
|
1070
1439
|
for (const u of plan.unchanged) {
|
|
1071
1440
|
if (u.content != null) {
|
|
1072
1441
|
pushindex.record(index, { ...u, hash: pushindex.hashContent(u.content) }, pushedAt);
|
|
1073
1442
|
}
|
|
1074
1443
|
}
|
|
1075
|
-
pushindex.prune(index, new Set(
|
|
1444
|
+
pushindex.prune(index, new Set(presentUploads.map((u) => u.relPath)));
|
|
1076
1445
|
pushindex.write(dir, index);
|
|
1077
1446
|
}
|
|
1078
1447
|
|
|
1448
|
+
/**
|
|
1449
|
+
* El bloque `agent` que se declara al proponer, o null si nadie lo declaró.
|
|
1450
|
+
*
|
|
1451
|
+
* `--model` y `--session` sin `--agent` son un error y no un descuido que se
|
|
1452
|
+
* pueda completar solo: decir qué modelo escribió algo sin decir qué
|
|
1453
|
+
* herramienta lo corrió deja una procedencia que no se puede rastrear hasta
|
|
1454
|
+
* ningún lado.
|
|
1455
|
+
*/
|
|
1456
|
+
function buildAgent(args) {
|
|
1457
|
+
const name = typeof args.agent === 'string' && args.agent.trim() ? args.agent.trim() : null;
|
|
1458
|
+
const model = typeof args.model === 'string' && args.model.trim() ? args.model.trim() : null;
|
|
1459
|
+
const session = typeof args.session === 'string' && args.session.trim() ? args.session.trim() : null;
|
|
1460
|
+
|
|
1461
|
+
if (!name) {
|
|
1462
|
+
if (model || session) {
|
|
1463
|
+
return { error: 'Para declarar procedencia hace falta --agent <nombre>; --model y --session solos no alcanzan.' };
|
|
1464
|
+
}
|
|
1465
|
+
return null;
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
return {
|
|
1469
|
+
name: name.slice(0, AGENT_FIELD_MAX),
|
|
1470
|
+
...(model ? { model: model.slice(0, AGENT_FIELD_MAX) } : {}),
|
|
1471
|
+
...(session ? { session_id: session.slice(0, AGENT_FIELD_MAX) } : {}),
|
|
1472
|
+
tool: 'push',
|
|
1473
|
+
};
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
/**
|
|
1477
|
+
* El prompt que se sube, si quien llama lo pidió explícitamente.
|
|
1478
|
+
*
|
|
1479
|
+
* No se infiere del entorno ni se lee de ningún archivo por convención: un
|
|
1480
|
+
* prompt puede llevar adentro cualquier cosa que alguien haya pegado en su
|
|
1481
|
+
* terminal, y subir eso sin que nadie lo haya decidido es lo mismo que
|
|
1482
|
+
* filtrarlo.
|
|
1483
|
+
*
|
|
1484
|
+
* Un prompt más largo que el techo se descarta con aviso, no se trunca: medio
|
|
1485
|
+
* prompt es peor que ninguno porque parece completo.
|
|
1486
|
+
*/
|
|
1487
|
+
function readPrompt(args, agent) {
|
|
1488
|
+
const file = typeof args['prompt-file'] === 'string' ? args['prompt-file'].trim() : '';
|
|
1489
|
+
const inline = typeof args.prompt === 'string' ? args.prompt : '';
|
|
1490
|
+
|
|
1491
|
+
if (!file && !inline) return { prompt: null, warnings: [] };
|
|
1492
|
+
if (file && inline) {
|
|
1493
|
+
return { error: 'Elegí uno: --prompt o --prompt-file, no los dos.' };
|
|
1494
|
+
}
|
|
1495
|
+
if (!agent || agent.error) {
|
|
1496
|
+
return { error: 'El prompt se guarda dentro de la procedencia del agente: agregá --agent <nombre>.' };
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
let text = inline;
|
|
1500
|
+
if (file) {
|
|
1501
|
+
const resolved = path.resolve(file);
|
|
1502
|
+
if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) {
|
|
1503
|
+
return { error: `No encontramos el archivo del prompt: ${resolved}` };
|
|
1504
|
+
}
|
|
1505
|
+
try {
|
|
1506
|
+
text = fs.readFileSync(resolved, 'utf8');
|
|
1507
|
+
} catch (e) {
|
|
1508
|
+
return { error: `No se pudo leer el prompt: ${e.message}` };
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
if (!text.trim()) return { prompt: null, warnings: [] };
|
|
1513
|
+
|
|
1514
|
+
const bytes = Buffer.byteLength(text, 'utf8');
|
|
1515
|
+
if (bytes > PROMPT_MAX_BYTES) {
|
|
1516
|
+
return {
|
|
1517
|
+
prompt: null,
|
|
1518
|
+
warnings: [
|
|
1519
|
+
`El prompt pesa ${Math.round(bytes / 1024)} KB y el techo es ` +
|
|
1520
|
+
`${PROMPT_MAX_BYTES / 1024} KB: se guarda el cambio sin él.`,
|
|
1521
|
+
],
|
|
1522
|
+
};
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
return { prompt: text, warnings: [] };
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1079
1528
|
/**
|
|
1080
1529
|
* Arma el mensaje del commit. Si vino `--card`, se agrega como trailer
|
|
1081
1530
|
* `[card: <id>]` al final: queda visible en el historial y es fácil de grepear
|
|
1082
1531
|
* para cruzar un cambio con la tarjeta que lo pidió (Metrónomo u otro tablero).
|
|
1083
1532
|
* El backend corta en 500 caracteres, así que recortamos el mensaje del usuario
|
|
1084
1533
|
* antes que el trailer.
|
|
1534
|
+
*
|
|
1535
|
+
* Desde la Etapa 4 la tarjeta viaja ADEMÁS aparte, en `provenance.card`, que es
|
|
1536
|
+
* lo que se puede consultar (`GET /api/commits/<id>?card=…`). El trailer no se
|
|
1537
|
+
* saca ni se va a sacar: es el formato de todo lo ya guardado, y la procedencia
|
|
1538
|
+
* entra en el oid del commit, así que lo viejo no se puede completar.
|
|
1085
1539
|
*/
|
|
1086
1540
|
function buildMessage(userMsg, batchCount, total, partNo, nBatches, card) {
|
|
1087
1541
|
let base = userMsg || `shilo push: ${total} archivo(s)`;
|