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.
@@ -0,0 +1,532 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Del grafo de Shilo a objetos Git. Parte PURA.
5
+ *
6
+ * Etapa 5 del PLAN_MOTOR_SHILO, Parte A (§8.8). Acá no hay red ni `git`: recibe
7
+ * el grafo ya leído (commits, árboles y bytes de blobs) y devuelve los objetos
8
+ * Git con sus SHA-1. Escribirlos en un repo es de `shilo export` (Parte B).
9
+ *
10
+ * La conversión tiene que ser determinística: el mismo grafo da los mismos
11
+ * SHA-1 en cualquier máquina y cualquier día (invariante 2 de la etapa). Por eso
12
+ * nada de acá lee el reloj, el usuario que exporta ni el orden en que llegaron
13
+ * las filas. Cada trampa de la tabla de §8.8 (A1…A15) está marcada donde se
14
+ * resuelve, y cada una tiene su test.
15
+ *
16
+ * Los SHA-1 no reemplazan a los `oid` (SHA-256) del grafo: son una proyección
17
+ * (🔴 7 de §8.8, GitHub no acepta repos SHA-256). El puente es `mapa`.
18
+ */
19
+
20
+ const { createHash } = require('crypto');
21
+
22
+ // A3: en el objeto árbol el modo de carpeta va sin el cero. El `040000` del
23
+ // grafo es sólo de presentación.
24
+ const MODO_ARCHIVO = '100644';
25
+ const MODO_CARPETA = '40000';
26
+
27
+ // A7: quien firma lo que no tiene autor (los commits del backfill) y el commit
28
+ // de las notas. Fija: si cambiara, cambiarían los SHA-1 de todo lo exportado.
29
+ const IDENTIDAD_SHILO = Object.freeze({ name: 'Shilo', email: 'noreply@shilo.city' });
30
+
31
+ const REF_NOTAS = 'refs/notes/shilo';
32
+
33
+ // El oid del árbol vacío en el grafo: SHA-256 de la cadena vacía. Es también el
34
+ // del archivo vacío (ver `convertirGrafo`).
35
+ const OID_VACIO = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855';
36
+
37
+ // Mensaje del commit de notas. Fijo por la misma razón que la identidad.
38
+ const MENSAJE_NOTAS = 'Procedencia de Shilo\n';
39
+
40
+ // Asunto de un guardado sin mensaje (decidido el 2026-09-28, antes de la primera
41
+ // exportación real). Sin él, el pie `Shilo-Oid: …` queda como título en
42
+ // `git log --oneline`. Fijo: cambiarlo movería el SHA-1 de todos esos commits.
43
+ const ASUNTO_SIN_MENSAJE = 'Punto de guardado';
44
+
45
+ /**
46
+ * Error de la conversión. `code` es estable (lo lee `shilo export` para decir
47
+ * qué hacer) y `path`, cuando hay, es la ruta exacta dentro del proyecto.
48
+ */
49
+ class ErrorConversion extends Error {
50
+ constructor(code, message, extra = {}) {
51
+ super(message);
52
+ this.name = 'ErrorConversion';
53
+ this.code = code;
54
+ Object.assign(this, extra);
55
+ }
56
+ }
57
+
58
+ /**
59
+ * A1: SHA-1 de un objeto Git. Git no hashea el contenido pelado sino
60
+ * `"<tipo> <largo>\0" + contenido`, con el largo en bytes.
61
+ */
62
+ function gitSha1(tipo, cuerpo) {
63
+ const bytes = aBuffer(cuerpo);
64
+ return createHash('sha1')
65
+ .update(`${tipo} ${bytes.length}\0`, 'utf8')
66
+ .update(bytes)
67
+ .digest('hex');
68
+ }
69
+
70
+ /** Blob Git a partir de los bytes crudos. A15: sin normalizar CRLF ni BOM. */
71
+ function gitBlob(contenido) {
72
+ const cuerpo = aBuffer(contenido);
73
+ return { tipo: 'blob', cuerpo, sha1: gitSha1('blob', cuerpo) };
74
+ }
75
+
76
+ /**
77
+ * A2: el orden de un árbol Git. Por bytes UTF-8, y cada carpeta se compara como
78
+ * si su nombre terminara en `/`. No es el orden de `canonicalizeTree`, que
79
+ * compara unidades UTF-16 sin la barra: `a-b` y `a/` (el `-` es 0x2D, la `/` es
80
+ * 0x2F), o un emoji contra un carácter de U+E000–U+FFFF, salen al revés.
81
+ *
82
+ * @param {Array<{name, type}>} entradas - type: 'blob' | 'tree'
83
+ * @returns {Array} copia ordenada
84
+ */
85
+ function ordenGit(entradas) {
86
+ const clave = (e) => Buffer.from(e.type === 'tree' ? `${e.name}/` : e.name, 'utf8');
87
+ return entradas
88
+ .map((e) => ({ e, k: clave(e) }))
89
+ .sort((x, y) => Buffer.compare(x.k, y.k))
90
+ .map(({ e }) => e);
91
+ }
92
+
93
+ /**
94
+ * Árbol Git. A4: es binario — `modo SP nombre NUL` y los 20 bytes crudos del
95
+ * SHA-1 —, no texto.
96
+ *
97
+ * @param {Array<{name, type, sha1}>} entradas
98
+ */
99
+ function gitTree(entradas) {
100
+ const vistos = new Set();
101
+ for (const e of entradas) {
102
+ validarNombre(e.name);
103
+ if (vistos.has(e.name)) {
104
+ throw new ErrorConversion('NOMBRE_REPETIDO', `El nombre «${e.name}» está dos veces en la misma carpeta.`, { name: e.name });
105
+ }
106
+ vistos.add(e.name);
107
+ }
108
+
109
+ const partes = [];
110
+ for (const e of ordenGit(entradas)) {
111
+ const modo = e.type === 'tree' ? MODO_CARPETA : MODO_ARCHIVO;
112
+ partes.push(Buffer.from(`${modo} ${e.name}\0`, 'utf8'));
113
+ partes.push(Buffer.from(e.sha1, 'hex'));
114
+ }
115
+
116
+ const cuerpo = Buffer.concat(partes);
117
+ return { tipo: 'tree', cuerpo, sha1: gitSha1('tree', cuerpo) };
118
+ }
119
+
120
+ // Los caracteres que HFS+ ignora al comparar nombres: `.g‌it` es `.git`
121
+ // para macOS, y `git fsck` lo rechaza igual. Es la lista de `is_hfs_dotgit`.
122
+ const IGNORABLES_HFS = /[\u200C-\u200F\u202A-\u202E\u206A-\u206F\uFEFF]/g;
123
+
124
+ /**
125
+ * A6: un nombre que Git no admite dentro de un árbol.
126
+ *
127
+ * `.git` en cualquier combinación de mayúsculas —y sus variantes de NTFS
128
+ * (`git~1`, espacios o puntos al final) y de HFS+— es lo que `git fsck` y el
129
+ * `fsck` de GitHub rebotan. `filenames.js` deja pasar `.git` porque su lista
130
+ * negra piensa en Windows, no en Git (🔴 2). No se renombra: se corta.
131
+ */
132
+ function validarNombre(nombre) {
133
+ if (typeof nombre !== 'string' || nombre === '' || nombre === '.' || nombre === '..' || nombre.includes('/') || nombre.includes('\0')) {
134
+ throw new ErrorConversion('NOMBRE_INVALIDO', `«${nombre}» no es un nombre válido para Git.`, { name: nombre });
135
+ }
136
+
137
+ const plano = nombre.replace(IGNORABLES_HFS, '').replace(/[. ]+$/, '').toLowerCase();
138
+ if (plano === '.git' || plano === 'git~1') {
139
+ throw new ErrorConversion(
140
+ 'NOMBRE_GIT',
141
+ `«${nombre}» es un nombre reservado por Git y ningún repositorio Git lo acepta.`,
142
+ { name: nombre }
143
+ );
144
+ }
145
+ }
146
+
147
+ /**
148
+ * A7 + A8: la línea de firma, `Nombre <correo> segundos +0000`.
149
+ *
150
+ * El nombre y el correo se limpian como los limpia Git (`fmt_ident`): fuera
151
+ * `<`, `>` y saltos de línea, y fuera la «basura» de los bordes (espacios y
152
+ * `,:;"'\`; el punto no, por nombres como «Jr.»). Así la firma es la misma que
153
+ * Git escribiría con esos datos.
154
+ *
155
+ * La fecha se lee del VALOR, no del texto: PostgREST puede devolver `Z` o
156
+ * `+00:00`, y con microsegundos. El milisegundo se trunca, y el huso es siempre
157
+ * `+0000` porque el grafo no guarda el de quien guardó.
158
+ */
159
+ function firmaGit(identidad, fecha) {
160
+ const nombre = limpiarIdentidad(identidad && identidad.name);
161
+ const correo = limpiarIdentidad(identidad && identidad.email);
162
+ if (!nombre || !correo) {
163
+ throw new ErrorConversion('IDENTIDAD_INCOMPLETA', 'Falta el nombre o el correo de quien firma.');
164
+ }
165
+ return `${nombre} <${correo}> ${segundosUnix(fecha)} +0000`;
166
+ }
167
+
168
+ // La «basura» de los bordes según `crud()` de Git: control y espacio (<= 32) y
169
+ // estos signos. Por código y no con un rango en el regex, igual que en
170
+ // `filenames.js`: escritos literales, los bytes de control no se ven.
171
+ const BASURA = new Set([',', ':', ';', '<', '>', '"', '\\', '\'']);
172
+ const esBasura = (c) => c.charCodeAt(0) <= 32 || BASURA.has(c);
173
+
174
+ function limpiarIdentidad(valor) {
175
+ const s = String(valor || '').replace(/[<>\n]/g, '').replace(/\0/g, '');
176
+ let i = 0;
177
+ let j = s.length;
178
+ while (i < j && esBasura(s[i])) i += 1;
179
+ while (j > i && esBasura(s[j - 1])) j -= 1;
180
+ return s.slice(i, j);
181
+ }
182
+
183
+ function segundosUnix(fecha) {
184
+ const ms = fecha instanceof Date ? fecha.getTime() : Date.parse(fecha);
185
+ if (!Number.isFinite(ms)) {
186
+ throw new ErrorConversion('FECHA_INVALIDA', `«${fecha}» no es una fecha.`);
187
+ }
188
+ return Math.floor(ms / 1000);
189
+ }
190
+
191
+ /**
192
+ * A11: las pocas líneas legibles que van al pie del mensaje. Entran en el SHA-1,
193
+ * así que van sólo las cortas y estables; lo voluminoso va en la nota.
194
+ *
195
+ * El agente sale del primer nivel o, en un commit de dos padres, de lo que se
196
+ * propuso (`proposed`): el que aplica una propuesta de un agente la firma, pero
197
+ * el contenido lo escribió el agente.
198
+ */
199
+ function trailers(provenance, oid) {
200
+ const p = provenance || {};
201
+ const agente = p.agent || (p.proposed && p.proposed.agent) || null;
202
+ const linea = (clave, valor) => {
203
+ const texto = String(valor === null || valor === undefined ? '' : valor).replace(/[\r\n]+/g, ' ').trim();
204
+ return texto ? `${clave}: ${texto}` : null;
205
+ };
206
+
207
+ return [
208
+ agente && linea('Shilo-Agent', agente.name),
209
+ agente && linea('Shilo-Model', agente.model),
210
+ linea('Shilo-Card', p.card),
211
+ linea('Shilo-Proposal', p.proposal_id),
212
+ linea('Shilo-Oid', oid)
213
+ ].filter(Boolean);
214
+ }
215
+
216
+ /**
217
+ * A11: la nota con la procedencia completa, como `canonicalJson`. Las notas no
218
+ * entran en el SHA-1 del commit, así que acá sí va todo (`resolutions`, `kept`).
219
+ * A14: un `prompt_ref` viaja como oid, nunca el texto del prompt.
220
+ * Sin procedencia no hay nota.
221
+ */
222
+ function notaShilo(provenance) {
223
+ if (!provenance || typeof provenance !== 'object' || Object.keys(provenance).length === 0) return null;
224
+ return `${canonicalJson(provenance)}\n`;
225
+ }
226
+
227
+ /**
228
+ * Commit Git.
229
+ *
230
+ * A9: el committer es el autor, con la misma fecha. Si fuera «quien exporta,
231
+ * ahora», dos exportaciones del mismo grafo darían SHA-1 distintos.
232
+ * A10: Git exige el `\n` final; los saltos de línea van crudos. Un mensaje
233
+ * vacío es válido.
234
+ * A12: los padres en el orden guardado. El primero es `main`.
235
+ *
236
+ * @param {Object} c
237
+ * @param {string} c.tree - SHA-1 del árbol
238
+ * @param {string[]} [c.parents] - SHA-1 de los padres, en orden
239
+ * @param {string} c.firma - salida de `firmaGit`
240
+ * @param {string} [c.message]
241
+ * @param {string[]} [c.trailers]
242
+ */
243
+ function gitCommit({ tree, parents = [], firma, message = '', trailers: pie = [] }) {
244
+ let texto = String(message || '');
245
+ if (texto && !texto.endsWith('\n')) texto += '\n';
246
+ if (pie.length > 0) texto += `${texto ? '\n' : ''}${pie.join('\n')}\n`;
247
+
248
+ const cabecera = [`tree ${tree}`, ...parents.map((p) => `parent ${p}`), `author ${firma}`, `committer ${firma}`];
249
+ const cuerpo = Buffer.from(`${cabecera.join('\n')}\n\n${texto}`, 'utf8');
250
+ return { tipo: 'commit', cuerpo, sha1: gitSha1('commit', cuerpo) };
251
+ }
252
+
253
+ /**
254
+ * Convierte el grafo alcanzable desde `heads` en objetos Git.
255
+ *
256
+ * A13: se recorre desde las cabezas hacia atrás por los padres; un commit que
257
+ * perdió la carrera del CAS no es alcanzable y no sale. A5: una carpeta vacía —o
258
+ * que sólo tiene carpetas vacías— se omite y se lista en `vacias`, sin inventar
259
+ * un `.gitkeep`.
260
+ *
261
+ * El `oid` de cada blob se comprueba contra sus bytes: un blob que no describe
262
+ * su contenido es un grafo roto, y exportarlo lo esparciría.
263
+ *
264
+ * @param {Object} grafo
265
+ * @param {Object<string, Object>} grafo.commits - oid → fila de `commit_objects`
266
+ * @param {Object<string, Array<{name, type, oid}>>} grafo.trees - oid → entradas
267
+ * @param {Object<string, Buffer|string>} grafo.blobs - oid → bytes
268
+ * @param {Object<string, string>} grafo.heads - nombre de ref → oid de commit
269
+ * @param {Object<string, {name, email}>} [grafo.identidades] - author_id → identidad congelada
270
+ * @param {Object} [grafo.mapaPrevio] - el `mapa` de una exportación anterior:
271
+ * lo que se vuelve a convertir tiene que dar lo mismo (`SHA1_MOVIDO`), y los
272
+ * árboles y blobs que no vinieron se toman de ahí sin bajarlos. Los objetos
273
+ * sembrados no están en `objetos`: ya están en el repo.
274
+ * @returns {{ objetos: Map, mapa: Object, refs: Object, notas: Object, vacias: string[] }}
275
+ */
276
+ function convertirGrafo({ commits, trees, blobs, heads, identidades = {}, mapaPrevio = {} }) {
277
+ const objetos = new Map();
278
+ // Un mapa por tipo, no uno solo: en el grafo el árbol vacío y el archivo vacío
279
+ // tienen el MISMO oid (los dos son el SHA-256 de la cadena vacía), y en Git
280
+ // son dos objetos distintos.
281
+ const mapa = { commits: {}, trees: {}, blobs: {} };
282
+ const vacias = new Set();
283
+ const notas = {};
284
+
285
+ const guardar = (tipo, oid, objeto) => {
286
+ objetos.set(objeto.sha1, objeto);
287
+ // Una exportación incremental no confía en lo viejo: lo recalcula y exige
288
+ // el mismo SHA-1. Si cambió, es un bug de esta conversión, no algo a pisar.
289
+ const previo = mapaPrevio[tipo] && mapaPrevio[tipo][oid];
290
+ if (previo && previo !== objeto.sha1) {
291
+ throw new ErrorConversion(
292
+ 'SHA1_MOVIDO',
293
+ `El objeto ${oid.slice(0, 12)} daba ${previo.slice(0, 12)} y ahora da ${objeto.sha1.slice(0, 12)}.`,
294
+ { oid }
295
+ );
296
+ }
297
+ mapa[tipo][oid] = objeto.sha1;
298
+ return objeto.sha1;
299
+ };
300
+
301
+ // Incremental: un árbol o un blob que no vino pero ya se exportó se toma del
302
+ // mapa previo sin bajarlo. El oid es por contenido, así que el mismo oid es
303
+ // el mismo objeto Git, que ya está en el repo. Los commits no se siembran:
304
+ // son filas, bajarlos todos es barato, y así cada exportación los recalcula
305
+ // y los compara contra el mapa (y la ref de notas sale completa).
306
+ const sembrado = (tipo, oid) => {
307
+ const previo = mapaPrevio[tipo] && mapaPrevio[tipo][oid];
308
+ if (previo) mapa[tipo][oid] = previo;
309
+ return Boolean(previo);
310
+ };
311
+
312
+ const blobSha1 = (oid, ruta) => {
313
+ if (mapa.blobs[oid]) return mapa.blobs[oid];
314
+ const bytes = blobs[oid];
315
+ if (bytes === undefined && sembrado('blobs', oid)) return mapa.blobs[oid];
316
+ if (bytes === undefined) {
317
+ throw new ErrorConversion('FALTA_OBJETO', `Falta el contenido de ${ruta}.`, { oid, path: ruta });
318
+ }
319
+ const buf = aBuffer(bytes);
320
+ if (createHash('sha256').update(buf).digest('hex') !== oid) {
321
+ throw new ErrorConversion('BLOB_CORRUPTO', `El contenido de ${ruta} no coincide con su identidad.`, { oid, path: ruta });
322
+ }
323
+ return guardar('blobs', oid, gitBlob(buf));
324
+ };
325
+
326
+ // Cache por oid de árbol: `null` = quedó vacío en Git. El árbol no depende de
327
+ // su ruta, pero la lista de vacías sí, así que se recuerda por oid qué rutas
328
+ // relativas quedaron vacías y se reportan con cada ruta donde aparece.
329
+ const arboles = new Map();
330
+
331
+ const convertirArbol = (oid, ruta) => {
332
+ if (!arboles.has(oid)) {
333
+ const entradas = trees[oid] || (oid === OID_VACIO ? [] : null);
334
+ // Sembrado: las carpetas vacías de adentro ya se informaron cuando se
335
+ // exportó por primera vez; acá no se vuelven a listar.
336
+ if (!entradas && sembrado('trees', oid)) {
337
+ arboles.set(oid, { sha1: mapa.trees[oid], vaciasRel: [] });
338
+ return arboles.get(oid);
339
+ }
340
+ if (!entradas) throw new ErrorConversion('FALTA_OBJETO', `Falta la carpeta ${ruta || '/'}.`, { oid, path: ruta });
341
+
342
+ const salida = [];
343
+ const vaciasRel = [];
344
+ for (const e of entradas) {
345
+ const hija = ruta ? `${ruta}/${e.name}` : e.name;
346
+ try {
347
+ validarNombre(e.name);
348
+ } catch (err) {
349
+ err.path = hija;
350
+ throw err;
351
+ }
352
+ if (e.type === 'tree') {
353
+ const sub = convertirArbol(e.oid, hija);
354
+ vaciasRel.push(...sub.vaciasRel.map((r) => `${e.name}/${r}`));
355
+ if (sub.sha1) salida.push({ name: e.name, type: 'tree', sha1: sub.sha1 });
356
+ else vaciasRel.push(e.name);
357
+ } else {
358
+ salida.push({ name: e.name, type: 'blob', sha1: blobSha1(e.oid, hija) });
359
+ }
360
+ }
361
+
362
+ let sha1 = null;
363
+ if (salida.length > 0) {
364
+ try {
365
+ sha1 = guardar('trees', oid, gitTree(salida));
366
+ } catch (err) {
367
+ if (!err.path) err.path = ruta || '/';
368
+ throw err;
369
+ }
370
+ }
371
+ arboles.set(oid, { sha1, vaciasRel });
372
+ }
373
+
374
+ const hecho = arboles.get(oid);
375
+ for (const r of hecho.vaciasRel) vacias.add(ruta ? `${ruta}/${r}` : r);
376
+ return hecho;
377
+ };
378
+
379
+ const raiz = (oid) => {
380
+ const { sha1 } = convertirArbol(oid, '');
381
+ // La raíz vacía sí existe en Git: el árbol vacío.
382
+ return sha1 || guardar('trees', oid, gitTree([]));
383
+ };
384
+
385
+ const convertirCommit = (oid) => {
386
+ // Recorrido iterativo en post-orden: una historia larga desbordaría la pila
387
+ // con recursión.
388
+ const pila = [oid];
389
+ while (pila.length > 0) {
390
+ const actual = pila[pila.length - 1];
391
+ if (mapa.commits[actual]) { pila.pop(); continue; }
392
+
393
+ const fila = commits[actual];
394
+ if (!fila) throw new ErrorConversion('FALTA_OBJETO', `Falta el punto de guardado ${actual.slice(0, 12)}.`, { oid: actual });
395
+
396
+ const pendientes = (fila.parents || []).filter((p) => !mapa.commits[p]);
397
+ if (pendientes.length > 0) {
398
+ pila.push(...pendientes);
399
+ continue;
400
+ }
401
+ pila.pop();
402
+
403
+ const sha1 = guardar('commits', actual, gitCommit({
404
+ tree: raiz(fila.tree_oid),
405
+ parents: (fila.parents || []).map((p) => mapa.commits[p]),
406
+ firma: firmaGit(identidadDe(fila, identidades), fila.created_at),
407
+ message: String(fila.message || '').trim() === '' ? ASUNTO_SIN_MENSAJE : fila.message,
408
+ trailers: trailers(fila.provenance, actual)
409
+ }));
410
+
411
+ const nota = notaShilo(fila.provenance);
412
+ if (nota) notas[sha1] = nota;
413
+ }
414
+ return mapa.commits[oid];
415
+ };
416
+
417
+ const refs = {};
418
+ for (const nombre of Object.keys(heads).sort()) {
419
+ refs[nombre] = convertirCommit(heads[nombre]);
420
+ }
421
+
422
+ // El mapa que se devuelve es el que hay que guardar: el previo más lo nuevo.
423
+ // Con una raíz sembrada no se recorre lo de adentro, así que lo convertido
424
+ // en esta vuelta sólo no alcanza para la próxima.
425
+ const unido = {};
426
+ for (const tipo of Object.keys(mapa)) unido[tipo] = { ...(mapaPrevio[tipo] || {}), ...mapa[tipo] };
427
+
428
+ return { objetos, mapa: unido, refs, notas, vacias: [...vacias].sort() };
429
+ }
430
+
431
+ /**
432
+ * A7: quién firma un commit. Sin autor (backfill) firma Shilo; con autor, su
433
+ * identidad congelada, que la trae quien llama. Leer `username` en vivo haría
434
+ * que el mismo grafo diera otro SHA-1 el día que alguien se cambia el nombre.
435
+ */
436
+ function identidadDe(fila, identidades) {
437
+ if (!fila.author_id) return IDENTIDAD_SHILO;
438
+ const identidad = identidades[fila.author_id];
439
+ if (!identidad) {
440
+ throw new ErrorConversion('FALTA_IDENTIDAD', `Falta la identidad congelada de ${fila.author_id}.`, { author_id: fila.author_id });
441
+ }
442
+ return identidad;
443
+ }
444
+
445
+ /**
446
+ * El commit de `refs/notes/shilo`: un árbol con un blob por commit anotado,
447
+ * nombrado con su SHA-1, y un commit sin padre firmado por Shilo.
448
+ *
449
+ * Sin padre y con la fecha del commit anotado más reciente, para que dependa
450
+ * sólo del grafo: exportar dos veces da la misma ref de notas. El costo es que
451
+ * una exportación incremental reemplaza la ref en vez de avanzarla.
452
+ *
453
+ * @param {Object<string, string>} notas - SHA-1 del commit → texto
454
+ * @param {string|Date} fecha
455
+ * @returns {{ objetos: Array, sha1: string } | null}
456
+ */
457
+ function gitNotas(notas, fecha) {
458
+ const claves = Object.keys(notas);
459
+ if (claves.length === 0) return null;
460
+
461
+ const blobs = claves.map((sha1) => ({ sha1, blob: gitBlob(notas[sha1]) }));
462
+ const arbol = gitTree(blobs.map(({ sha1, blob }) => ({ name: sha1, type: 'blob', sha1: blob.sha1 })));
463
+ const commit = gitCommit({ tree: arbol.sha1, firma: firmaGit(IDENTIDAD_SHILO, fecha), message: MENSAJE_NOTAS });
464
+
465
+ return { objetos: [...blobs.map((b) => b.blob), arbol, commit], sha1: commit.sha1 };
466
+ }
467
+
468
+ /**
469
+ * JSON con las claves ordenadas en todos los niveles. Copia de `canonicalJson`
470
+ * de `backend/utils/objectGraph.js` (el CLI no importa del backend); el test de
471
+ * espejo las compara.
472
+ */
473
+ function canonicalJson(value) {
474
+ if (value === undefined || value === null) return '{}';
475
+ if (typeof value !== 'object') return JSON.stringify(value);
476
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
477
+
478
+ const keys = Object.keys(value).sort();
479
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(value[k])}`).join(',')}}`;
480
+ }
481
+
482
+ function aBuffer(x) {
483
+ return Buffer.isBuffer(x) ? x : Buffer.from(String(x), 'utf8');
484
+ }
485
+
486
+ /**
487
+ * Las entradas de un árbol de Shilo a partir de su texto serializado, tal como
488
+ * lo devuelve `GET /api/projects/graph?objects=trees` (B1).
489
+ *
490
+ * Primero se comprueba que `sha256(texto) === oid`: el export no le cree al
491
+ * servidor, igual que no le cree a los blobs. Después se parsea con las mismas
492
+ * reglas que `parseTree` del backend (el test de deriva las compara), y se
493
+ * exige que cada línea sea una entrada completa: un árbol que parsea "más o
494
+ * menos" daría un repositorio que parece bien y no lo está.
495
+ */
496
+ function leerArbolShilo(oid, texto) {
497
+ if (createHash('sha256').update(texto, 'utf8').digest('hex') !== oid) {
498
+ throw new ErrorConversion('ARBOL_CORRUPTO', `El árbol ${oid.slice(0, 8)} no coincide con su identidad.`, { oid });
499
+ }
500
+ if (texto === '') return [];
501
+
502
+ return texto.split('\n').filter((linea) => linea.length > 0).map((linea) => {
503
+ const [mode, type, hijo, ...resto] = linea.split('\t');
504
+ const name = resto.join('\t');
505
+ if ((type !== 'blob' && type !== 'tree') || !/^[0-9a-f]{64}$/.test(hijo || '') || name === '') {
506
+ throw new ErrorConversion('ARBOL_CORRUPTO', `El árbol ${oid.slice(0, 8)} tiene una entrada ilegible.`, { oid, linea });
507
+ }
508
+ return { mode, type, oid: hijo, name };
509
+ });
510
+ }
511
+
512
+ module.exports = {
513
+ leerArbolShilo,
514
+ gitSha1,
515
+ gitBlob,
516
+ gitTree,
517
+ gitCommit,
518
+ gitNotas,
519
+ ordenGit,
520
+ firmaGit,
521
+ trailers,
522
+ notaShilo,
523
+ validarNombre,
524
+ convertirGrafo,
525
+ canonicalJson,
526
+ ErrorConversion,
527
+ IDENTIDAD_SHILO,
528
+ ASUNTO_SIN_MENSAJE,
529
+ REF_NOTAS,
530
+ MODO_ARCHIVO,
531
+ MODO_CARPETA
532
+ };
package/src/remote.js CHANGED
@@ -11,13 +11,22 @@
11
11
  * {
12
12
  * "projectId": "<uuid>",
13
13
  * "baseURL": "https://www.shilo.city",
14
- * "lastSync": "2026-07-27T12:00:00.000Z"
14
+ * "lastSync": "2026-07-27T12:00:00.000Z",
15
+ * "base": "<oid de la versión actual que esta carpeta vio por última vez>"
15
16
  * }
16
17
  *
17
18
  * `lastSync` es la marca de la última vez que esta carpeta quedó alineada con
18
19
  * el remoto (fin de un push o pull exitoso). `status` la usa para distinguir
19
20
  * "el remoto cambió desde que sincronicé" de "esto lo subí yo".
20
21
  *
22
+ * `base` es lo mismo pero exacto: el punto de guardado del que parte esta
23
+ * carpeta. `push` lo manda con cada propuesta para que el servidor sepa contra
24
+ * qué comparar — un archivo que otra persona cambió después de esa base y que
25
+ * acá también se cambió es un conflicto, no una sobreescritura. Va acá y no en
26
+ * `.shilo/index.json` porque el índice es una optimización descartable
27
+ * (`--no-index` lo ignora) y la base es un dato: perderla no rompe nada, pero
28
+ * vuelve el push siguiente ciego a lo que cambiaron los demás.
29
+ *
21
30
  * NO contiene credenciales (esas están en ~/.shilo/config.json, cifradas), así
22
31
  * que es seguro commitearlo al repo si el usuario quiere.
23
32
  */
@@ -49,17 +58,19 @@ function read(dir) {
49
58
  projectId: raw.projectId || null,
50
59
  baseURL: raw.baseURL || null,
51
60
  lastSync: raw.lastSync || null,
61
+ base: typeof raw.base === 'string' && /^[0-9a-f]{64}$/.test(raw.base) ? raw.base : null,
52
62
  };
53
63
  }
54
64
 
55
65
  /**
56
66
  * Escribe `.shilo.json` en `dir`. Devuelve la ruta escrita.
57
67
  */
58
- function write(dir, { projectId, baseURL, lastSync }) {
68
+ function write(dir, { projectId, baseURL, lastSync, base }) {
59
69
  const p = remotePath(dir);
60
70
  const onDisk = { projectId };
61
71
  if (baseURL) onDisk.baseURL = baseURL;
62
72
  if (lastSync) onDisk.lastSync = lastSync;
73
+ if (base) onDisk.base = base;
63
74
  fs.writeFileSync(p, JSON.stringify(onDisk, null, 2) + '\n', 'utf8');
64
75
  return p;
65
76
  }
@@ -69,13 +80,14 @@ function write(dir, { projectId, baseURL, lastSync }) {
69
80
  * remote. No es fatal si falla (la carpeta puede ser de sólo lectura): devuelve
70
81
  * `false` y el caller decide si avisar.
71
82
  */
72
- function touchSync(dir, { projectId, baseURL } = {}) {
83
+ function touchSync(dir, { projectId, baseURL, base } = {}) {
73
84
  try {
74
85
  const current = read(dir) || {};
75
86
  write(dir, {
76
87
  projectId: projectId || current.projectId,
77
88
  baseURL: baseURL || current.baseURL,
78
89
  lastSync: new Date().toISOString(),
90
+ base: base || current.base || null,
79
91
  });
80
92
  return true;
81
93
  } catch (_) {
package/src/sync.js CHANGED
@@ -13,7 +13,7 @@
13
13
  const fs = require('fs');
14
14
  const path = require('path');
15
15
  const config = require('./config');
16
- const { walk } = require('./walk');
16
+ const { walk, makePathMatcher } = require('./walk');
17
17
 
18
18
  const CONCURRENCY = 8; // pool para el N+1 de listar carpetas (el backend no tiene árbol recursivo)
19
19
  const MAX_CONCURRENCY = 32;
@@ -409,6 +409,9 @@ module.exports = {
409
409
  runPool,
410
410
  walkRemote,
411
411
  walkLocal,
412
+ // Re-exportado para que los comandos filtren el árbol REMOTO con los mismos
413
+ // ignores que `walkLocal` aplica al local, sin importar `walk.js` aparte.
414
+ makePathMatcher,
412
415
  compareTrees,
413
416
  buildAuthorIndex,
414
417
  describeAuthor,
package/src/walk.js CHANGED
@@ -195,6 +195,35 @@ function loadIgnoreRules(rootDir, extraLines = []) {
195
195
  return rules;
196
196
  }
197
197
 
198
+ /**
199
+ * Matcher de ignores por RUTA DE REPO, sin tocar disco.
200
+ *
201
+ * `walk()` decide los ignores mientras recorre el árbol local, así que sólo
202
+ * contesta por lo que existe acá. Esto contesta la otra mitad: dada una ruta
203
+ * que vino del REMOTO, ¿estaría ignorada localmente? Sin esto los ignores valen
204
+ * en un solo sentido —el push— y el árbol remoto nunca se filtra: `status`
205
+ * cuenta como "atrás" archivos que jamás va a tener y `pull` baja cosas
206
+ * ignoradas.
207
+ *
208
+ * Sólo carga las reglas de la raíz: un ignore anidado vive dentro de una
209
+ * carpeta que, si el archivo sólo existe en el remoto, puede no estar en disco.
210
+ * Es una aproximación conservadora — ignora de menos, nunca de más.
211
+ */
212
+ function makePathMatcher(rootDir, { extraIgnore = [] } = {}) {
213
+ const isIgnored = makeMatcher(loadIgnoreRules(rootDir, extraIgnore));
214
+ return function isIgnoredPath(repoPath) {
215
+ const parts = String(repoPath).split('/');
216
+ // Cada ancestro se consulta como CARPETA: `makeMatcher` saltea las reglas
217
+ // `dirOnly` cuando `isDir` es false, así que preguntar derecho por
218
+ // `.claude/settings.local.json` daría false y la regla `.claude/` no
219
+ // alcanzaría nunca a lo de adentro.
220
+ for (let i = 0; i < parts.length - 1; i++) {
221
+ if (isIgnored(parts.slice(0, i + 1).join('/'), true)) return true;
222
+ }
223
+ return isIgnored(repoPath, false);
224
+ };
225
+ }
226
+
198
227
  /**
199
228
  * Recorre `rootDir` y devuelve:
200
229
  * {
@@ -312,6 +341,7 @@ module.exports = {
312
341
  loadIgnoreRules,
313
342
  loadDirRules,
314
343
  makeMatcher,
344
+ makePathMatcher,
315
345
  compileRule,
316
346
  globToRegExp,
317
347
  looksLikeGitDir,