shilo-cli 0.6.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.
@@ -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
@@ -237,9 +244,11 @@ function neededFolders(relPaths) {
237
244
  * shilo push [carpeta] [--project <uuid>] [-m <msg>] [--dry-run] [--yes]
238
245
  * [--allow-secrets] [--concurrency <n>] [--no-index] [--verbose]
239
246
  *
240
- * Replica una carpeta local a un repo de Shilo: crea archivos nuevos, actualiza
241
- * los que cambiaron, salta binarios/oversize/nombres inválidos, y registra todo
242
- * en uno o más commits.
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.
243
252
  */
244
253
  async function push(args) {
245
254
  const dir = path.resolve(args._[0] || '.');
@@ -504,30 +513,9 @@ async function push(args) {
504
513
 
505
514
  config.persistCredsIfChanged(cfg, mgr.current);
506
515
 
507
- // 5d) Atribución: para los archivos que vamos a pisar, quién los tocó último.
508
- // Es lo que distingue "esto lo subí yo antes" de "esto lo va a perder un
509
- // compañero". Best-effort: si el historial no se puede leer, seguimos.
510
- 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.
511
518
  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
519
 
532
520
  // 6) Reporte del plan.
533
521
  ui.info('');
@@ -552,38 +540,16 @@ async function push(args) {
552
540
 
553
541
  if (dryRun) {
554
542
  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}${authorNote(u)}`);
543
+ for (const u of plan.modified) ui.info(` ${ui.c.yellow('~')} ${u.relPath}`);
556
544
  for (const f of plan.failed) ui.info(` ${ui.c.red('!')} ${f.relPath} — ${f.reason}`);
557
545
  ui.info('');
558
- if (!myId && plan.modified.length > 0 && attribution.index.size > 0) {
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
- }
575
546
  ui.info(ui.c.dim('(--dry-run: no se subió nada)'));
576
547
  return plan.failed.length ? 1 : 0;
577
548
  }
578
549
 
579
- // 7) Confirmar (salvo --yes). Si vamos a pisar trabajo ajeno, lo decimos
580
- // antes de preguntar: es el caso que motivó todo este flujo.
581
- if (touchedByOthers.length > 0) {
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
-
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.
587
553
  if (!args.yes) {
588
554
  const ok = await prompt.confirm(`¿Subir ${toWrite} archivo(s) al proyecto?`, true);
589
555
  if (!ok) {
@@ -592,13 +558,7 @@ async function push(args) {
592
558
  }
593
559
  }
594
560
 
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
561
  const pushedAt = Date.now();
600
- let committed = 0;
601
- let commitCount = 0;
602
562
 
603
563
  /** Log por archivo: en TTY lo tapa la barra de progreso, así que va sólo con --verbose. */
604
564
  const logFile = (prog, msg) => {
@@ -614,395 +574,603 @@ async function push(args) {
614
574
  ui.error(msg);
615
575
  };
616
576
 
617
- // 8a) Carpetas faltantes, por nivel. El árbol remoto ya nos dice cuáles
618
- // existen, así que no hace falta listar antes de crear cada una; y crear
619
- // nivel por nivel (paralelo dentro del nivel, serie entre niveles)
620
- // garantiza que el padre exista sin depender del 409 del backend, que no
621
- // es confiable ante dos creaciones simultáneas de la misma carpeta.
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.
582
+ //
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.
622
585
  //
623
- // Se ejecuta a demanda: el endpoint de lote hace mkdir -p por su cuenta,
624
- // así que estos requests sólo hacen falta en el camino archivo-por-archivo.
625
- const failedFolders = [];
626
- let foldersEnsured = false;
627
-
628
- async function ensureFolders() {
629
- if (foldersEnsured) return;
630
- foldersEnsured = true;
631
- if (plan.added.length === 0) return;
632
-
633
- if (treeComplete) {
634
- const missing = [...neededFolders(plan.added.map((u) => u.relPath))].filter(
635
- (f) => !tree.folders.has(f)
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;
595
+ }
596
+ config.persistCredsIfChanged(cfg, mgr.current);
597
+
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
+ )
636
606
  );
637
- const byDepth = new Map();
638
- for (const f of missing) {
639
- const d = f.split('/').length;
640
- if (!byDepth.has(d)) byDepth.set(d, []);
641
- byDepth.get(d).push(f);
642
- }
607
+ }
643
608
 
644
- for (const depth of [...byDepth.keys()].sort((a, b) => a - b)) {
645
- const level = byDepth.get(depth);
646
- const results = await sync.runPool(level, throttle, async (folderPath) => {
647
- const parts = folderPath.split('/');
648
- const name = parts.pop();
649
- const creds = await mgr.valid();
650
- await client.createFolder(projectId, name, parts.join('/'), creds);
651
- return folderPath;
652
- });
653
- for (const r of results) {
654
- if (r && r.__error) {
655
- failedFolders.push({ folderPath: r.item, reason: r.__error });
656
- ui.error(`carpeta ${r.item} — ${r.__error}`);
657
- }
658
- }
659
- }
660
- } else {
661
- // Árbol incompleto: no sabemos qué existe. Volvemos al camino conservador.
662
- const ensuredCache = new Set();
663
- for (const u of plan.added) {
664
- try {
665
- const creds = await mgr.valid();
666
- await client.ensurePath(projectId, u.relPath, creds, ensuredCache);
667
- } catch (e) {
668
- failedFolders.push({ folderPath: path.posix.dirname(u.relPath), reason: e.message });
669
- ui.error(`${u.relPath} — ${e.message}`);
670
- }
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}`);
671
619
  }
672
620
  }
673
- config.persistCredsIfChanged(cfg, mgr.current);
674
- }
675
621
 
676
- const blocked = (relPath) =>
677
- failedFolders.some((f) => f.folderPath && relPath.startsWith(`${f.folderPath}/`));
678
-
679
- // 8b) Subidas. El costo dominante no es el trabajo del servidor sino la
680
- // latencia de cada round-trip, así que van agrupadas: el endpoint de lote
681
- // escribe hasta 100 archivos por request y crea las carpetas que falten.
682
- // El contenido se lee lote por lote, no todo de una: un repo grande no
683
- // tiene por qué entrar entero en memoria.
684
- const BATCH_MAX_FILES = COMMIT_BATCH; // 100: mismo techo que el commit
685
- const BATCH_MAX_BYTES = 4 * 1024 * 1024; // el server corta en 5 MB; margen
686
-
687
- const toUpload = [
688
- ...plan.added.map((u) => ({ u, isNew: true })),
689
- ...plan.modified.map((u) => ({ u, isNew: false })),
690
- ];
691
-
692
- // Empaquetado por cantidad Y por tamaño. Un archivo más grande que el
693
- // presupuesto termina solo en su lote, y ahí se sube por el endpoint
694
- // individual.
695
- //
696
- // Los lotes son HOMOGÉNEOS: o todo texto, o todo binario. Los dos grupos se
697
- // escriben por caminos distintos —lote JSON contra URL firmada— y un lote
698
- // mezclado obligaría a partirlo en el momento de escribir, justo donde después
699
- // hay que volver a juntar los cambios para un solo commit. Separarlos acá
700
- // deja esa parte sin ramas.
701
- //
702
- // El presupuesto de bytes no se le aplica a los binarios: su contenido nunca
703
- // entra en el payload de un request, así que un lote de diez PDF de 30 MB son
704
- // diez subidas directas y ningún JSON grande.
705
- const batches = [];
706
- for (const group of [toUpload.filter((e) => !e.u.binary), toUpload.filter((e) => e.u.binary)]) {
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 = [];
707
626
  let current = [];
708
627
  let currentBytes = 0;
709
- for (const e of group) {
710
- const bytes = e.u.binary ? 0 : typeof e.u.size === 'number' ? e.u.size : 0;
628
+ for (const u of toSend) {
629
+ const bytes = u.binary || u.size > PROPOSAL_INLINE_MAX ? 0 : u.size;
711
630
  const full =
712
- current.length >= BATCH_MAX_FILES || currentBytes + bytes > BATCH_MAX_BYTES;
631
+ current.length >= PROPOSAL_MAX_CHANGES || currentBytes + bytes > PROPOSAL_MAX_BYTES;
713
632
  if (current.length > 0 && full) {
714
633
  batches.push(current);
715
634
  current = [];
716
635
  currentBytes = 0;
717
636
  }
718
- current.push(e);
637
+ current.push(u);
719
638
  currentBytes += bytes;
720
639
  }
721
640
  if (current.length > 0) batches.push(current);
722
- }
723
641
 
724
- const upProg = ui.progress(toUpload.length, 'subiendo');
725
- let upDone = 0;
726
- const tickUp = (n = 1) => {
727
- upDone += n;
728
- upProg.update(upDone);
729
- };
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;
730
649
 
731
- const uploaded = []; // { relPath, size, mtimeMs, hash } para el índice
732
- let batchSupported = true; // se descubre en el primer lote
733
-
734
- /** Escribe un lote entero en un request. Devuelve un resultado por archivo. */
735
- async function writeViaBatch(entries) {
736
- const payload = entries.map((e) => ({
737
- path: e.u.relPath,
738
- content: e.content,
739
- mimeType: e.u.mimeType,
740
- }));
741
- const creds = await mgr.valid();
742
- const { results } = await client.writeBatch(projectId, payload, creds);
743
-
744
- return entries.map((e, i) => {
745
- const r = results[i];
746
- if (!r || !r.ok) {
747
- return { ok: false, reason: (r && r.error) || 'el servidor no devolvió resultado' };
748
- }
749
- const f = r.file || {};
750
- const action = r.action === 'created' ? 'added' : 'modified';
751
- const change = { file_id: f.id, action, new_storage_path: f.storage_path };
752
- if (action === 'modified') {
753
- // El backend devuelve el storage_path que acaba de reemplazar; sólo si
754
- // faltara caemos a lo que sabíamos del árbol remoto.
755
- change.previous_storage_path =
756
- f.previous_storage_path || (e.u.remote && e.u.remote.storage_path) || f.storage_path;
757
- // El hash del contenido que estamos reemplazando lo sabe únicamente el
758
- // cliente: cuando el commit llega al servidor, `files.content_hash` ya
759
- // fue pisado con el contenido nuevo. Puede ser null en archivos
760
- // anteriores a la migración 010.
761
- if (e.u.remote && e.u.remote.content_hash) {
762
- change.previous_content_hash = e.u.remote.content_hash;
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);
763
667
  }
764
668
  }
765
- return { ok: true, change, symbol: action === 'added' ? '+' : '~' };
766
- });
767
- }
669
+ if (loaded.length === 0) continue;
768
670
 
769
- /**
770
- * Camino de los binarios: URL firmada, un archivo a la vez y en paralelo.
771
- *
772
- * No se agrupan en un request como el texto porque no hay nada que agrupar:
773
- * cada byte va directo a Storage. Lo que sí se comparte es el control de
774
- * ritmo, que es el que evita que veinte subidas de 30 MB salgan juntas.
775
- *
776
- * El endpoint crea las carpetas intermedias por su cuenta, igual que el lote,
777
- * así que acá no hace falta `ensureFolders()`.
778
- */
779
- async function writeViaDirect(entries) {
780
- const results = await sync.runPool(entries, throttle, async (e) => {
781
- const creds = await mgr.valid();
782
- const { result } = await client.uploadBinary(
783
- projectId,
784
- {
785
- path: e.u.relPath,
786
- buffer: e.content,
787
- contentHash: pushindex.hashContent(e.content),
788
- mimeType: e.u.mimeType,
789
- },
790
- creds
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
791
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);
697
+ }
792
698
 
793
- const f = (result && result.file) || {};
794
- const action = result && result.action === 'created' ? 'added' : 'modified';
795
- const change = { file_id: f.id, action, new_storage_path: f.storage_path };
796
-
797
- if (action === 'modified') {
798
- change.previous_storage_path =
799
- f.previous_storage_path || (e.u.remote && e.u.remote.storage_path) || f.storage_path;
800
- // El hash de lo que estamos reemplazando: el servidor lo lee antes de
801
- // pisar la fila, que es el único momento en que existe. Si no vino, lo
802
- // sabemos por el árbol remoto.
803
- const previous =
804
- f.previous_content_hash || (e.u.remote && e.u.remote.content_hash) || null;
805
- if (previous) change.previous_content_hash = previous;
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);
806
709
  }
710
+ upDone += loaded.length;
711
+ upProg.update(upDone);
712
+ if (changes.length === 0) continue;
807
713
 
808
- return { ok: true, change, symbol: action === 'added' ? '+' : '~' };
809
- });
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();
810
753
 
811
- return results.map((r) => (r && r.__error ? { ok: false, reason: r.__error } : r));
754
+ return { failedWrites, committed: applied, commitCount: appliedCount, uploaded, open, stale, main, base };
812
755
  }
813
756
 
814
- /** Camino archivo-por-archivo: server sin /batch, o archivo demasiado grande. */
815
- async function writeViaSingle(entries) {
816
- await ensureFolders();
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
+ }
817
794
 
818
- const outcomes = new Array(entries.length);
819
- const queue = [];
820
- entries.forEach((e, i) => {
821
- if (e.isNew && blocked(e.u.relPath)) {
822
- outcomes[i] = { ok: false, reason: 'no se pudo crear su carpeta' };
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
+ }
823
811
  } else {
824
- queue.push({ e, i });
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
+ }
825
823
  }
826
- });
824
+ config.persistCredsIfChanged(cfg, mgr.current);
825
+ }
827
826
 
828
- const results = await sync.runPool(queue, throttle, async ({ e }) => {
829
- const u = e.u;
830
- if (e.isNew) {
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);
894
+
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) => {
831
932
  const creds = await mgr.valid();
832
- const created = await client.createFile(projectId, u.relPath, e.content, u.mimeType, creds);
833
- if (created.created && created.file) {
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);
834
998
  return {
835
999
  ok: true,
836
- symbol: '+',
1000
+ symbol: '~',
837
1001
  change: {
838
- file_id: created.file.id,
839
- action: 'added',
840
- new_storage_path: created.file.storage_path,
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,
841
1007
  },
842
1008
  };
843
1009
  }
844
- // 409: apareció entremedio; caemos a update.
845
- const writeCreds = await mgr.valid();
846
- const upd = await client.writeFile(projectId, u.relPath, e.content, u.mimeType, writeCreds);
1010
+
1011
+ const creds = await mgr.valid();
1012
+ const upd = await client.writeFile(projectId, u.relPath, e.content, u.mimeType, creds);
847
1013
  return {
848
1014
  ok: true,
849
1015
  symbol: '~',
850
1016
  change: {
851
1017
  file_id: upd.file.id,
852
1018
  action: 'modified',
853
- previous_storage_path:
854
- (created.file && created.file.storage_path) || upd.file.storage_path,
1019
+ previous_storage_path: u.remote ? u.remote.storage_path : upd.file.storage_path,
855
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
+ : {}),
856
1025
  },
857
1026
  };
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];
1027
+ });
889
1028
 
890
- // Un fallo de lectura (dejó de ser texto, permisos) saca a ese archivo del
891
- // lote pero no arrastra a los demás.
892
- const loaded = [];
893
- for (const e of entries) {
894
- try {
895
- e.content = await loadContent(e.u);
896
- loaded.push(e);
897
- } catch (err) {
898
- failedWrites.push({ relPath: e.u.relPath, reason: err.message });
899
- logError(upProg, `${e.u.relPath} — ${err.message}`);
900
- tickUp();
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;
901
1034
  }
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]));
1035
+ return outcomes;
920
1036
  }
921
1037
 
922
- if (texts.length > 0) {
923
- const oversize =
924
- texts.length === 1 && Buffer.byteLength(texts[0].content, 'utf8') > BATCH_MAX_BYTES;
925
- let res = null;
1038
+ for (let bi = 0; bi < batches.length; bi += 1) {
1039
+ const entries = batches[bi];
926
1040
 
927
- if (batchSupported && !oversize) {
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) {
928
1045
  try {
929
- res = await writeViaBatch(texts);
1046
+ e.content = await loadContent(e.u);
1047
+ loaded.push(e);
930
1048
  } catch (err) {
931
- if (err.notSupported) {
932
- batchSupported = false;
933
- upProg.clear();
934
- ui.warn('El servidor no acepta escritura por lotes; se sube archivo por archivo.');
935
- } else {
936
- // Falla del request entero (red, 5xx, permisos): ningún archivo de
937
- // texto del lote se escribió. Los binarios ya se resolvieron aparte
938
- // y no los arrastra.
939
- res = texts.map(() => ({ ok: false, reason: err.message }));
940
- }
1049
+ failedWrites.push({ relPath: e.u.relPath, reason: err.message });
1050
+ logError(upProg, `${e.u.relPath} — ${err.message}`);
1051
+ tickUp();
941
1052
  }
942
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]));
1071
+ }
943
1072
 
944
- if (!res) res = await writeViaSingle(texts);
945
- texts.forEach((e, i) => outcomeByEntry.set(e, res[i]));
946
- }
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
+ }
1092
+ }
1093
+ }
947
1094
 
948
- const outcomes = loaded.map((e) => outcomeByEntry.get(e));
949
- config.persistCredsIfChanged(cfg, mgr.current);
1095
+ if (!res) res = await writeViaSingle(texts);
1096
+ texts.forEach((e, i) => outcomeByEntry.set(e, res[i]));
1097
+ }
950
1098
 
951
- const batchChanges = [];
952
- for (let i = 0; i < loaded.length; i += 1) {
953
- const e = loaded[i];
954
- const o = outcomes[i];
955
- if (!o || !o.ok) {
956
- const reason = (o && o.reason) || 'error desconocido';
957
- failedWrites.push({ relPath: e.u.relPath, reason });
958
- logError(upProg, `${e.u.relPath} — ${reason}`);
959
- continue;
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}`);
960
1115
  }
961
- batchChanges.push(o.change);
962
- uploaded.push({ ...e.u, hash: pushindex.hashContent(e.content) });
963
- logFile(upProg, `${ui.c[o.symbol === '+' ? 'green' : 'yellow'](o.symbol)} ${e.u.relPath}`);
964
- }
965
- tickUp(loaded.length);
966
-
967
- // 9) Commit del lote, apenas termina de escribirse. Juntar todos los cambios
968
- // para un commit final ahorraba requests, pero dejaba una ventana larga
969
- // en la que los archivos ya estaban escritos y ningún commit los
970
- // mencionaba; si el push moría ahí, el historial quedaba mintiendo.
971
- if (batchChanges.length > 0) {
972
- const partNo = bi + 1;
973
- const msg = buildMessage(
974
- message,
975
- batchChanges.length,
976
- toUpload.length,
977
- partNo,
978
- batches.length,
979
- card
980
- );
981
- try {
982
- const creds = await mgr.valid();
983
- await client.createCommit(projectId, msg, batchChanges, creds);
984
- committed += batchChanges.length;
985
- commitCount += 1;
986
- config.persistCredsIfChanged(cfg, mgr.current);
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 });
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
+ }
990
1142
  }
991
- }
992
1143
 
993
- // El contenido ya se hasheó y se subió: soltarlo mantiene el pico de memoria
994
- // atado al tamaño del lote y no al del repo.
995
- for (const e of loaded) {
996
- e.content = null;
997
- e.u.content = null;
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
+ }
998
1150
  }
1151
+ upProg.clear();
1152
+
1153
+ config.persistCredsIfChanged(cfg, mgr.current);
1154
+ return { failedWrites, committed, commitCount, uploaded, open: [], stale: 0, main: null, base: null };
999
1155
  }
1000
- upProg.clear();
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;
1001
1167
 
1002
1168
  config.persistCredsIfChanged(cfg, mgr.current);
1003
1169
 
1004
1170
  // 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.
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.
1006
1174
  if (useIndex) {
1007
1175
  for (const u of uploaded) {
1008
1176
  pushindex.record(index, u, pushedAt);
@@ -1010,26 +1178,87 @@ async function push(args) {
1010
1178
  saveIndex(dir, index, plan, uploads, pushedAt);
1011
1179
  }
1012
1180
 
1013
- // 11) Anotar la sincronización: `status` la usa después para saber qué del
1014
- // remoto es posterior a lo último que subimos. Sólo si TODO salió bien:
1015
- // con archivos fallados, marcar "sincronizado" haría que status mienta.
1016
- if (failedWrites.length === 0 && plan.failed.length === 0) {
1017
- remote.touchSync(dir, { projectId, baseURL });
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 });
1018
1188
  }
1019
1189
 
1020
1190
  // 12) Resumen final.
1021
1191
  ui.info('');
1022
- if (failedWrites.length === 0 && plan.failed.length === 0) {
1023
- ui.success(`Push completo: ${committed} archivo(s) en ${commitCount} commit(s).`);
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
+ }
1024
1207
  return 0;
1025
1208
  }
1026
1209
  for (const f of plan.failed) ui.error(`${f.relPath} — ${f.reason}`);
1027
- ui.warn(
1028
- `Push con problemas: ${failedWrites.length + plan.failed.length} archivo(s)/commit(s) fallaron.`
1029
- );
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
+ }
1030
1218
  return 1;
1031
1219
  }
1032
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
+
1033
1262
  /**
1034
1263
  * Lee el contenido completo de un archivo, cacheado en la propia entrada.
1035
1264
  *