shilo-cli 0.7.0 → 0.9.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 +338 -255
- package/bin/shilo.js +211 -165
- package/package.json +1 -1
- package/src/ShiloClient.js +1066 -837
- package/src/args.js +32 -8
- package/src/checks.js +57 -0
- package/src/commands/deploys.js +105 -0
- package/src/commands/diff.js +19 -3
- package/src/commands/export.js +226 -0
- package/src/commands/init.js +8 -0
- package/src/commands/log.js +4 -0
- package/src/commands/pull.js +86 -2
- package/src/commands/push.js +668 -69
- package/src/commands/review.js +101 -0
- package/src/commands/status.js +148 -14
- package/src/exportgit.js +417 -0
- package/src/gitobjects.js +532 -0
- package/src/projectconfig.js +76 -0
- package/src/safelist.js +101 -0
- package/src/secrets.js +75 -9
- package/src/sync.js +56 -5
- package/src/walk.js +30 -0
|
@@ -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: `.git` 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
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `shilo.config.json` del lado del CLI (EP-S2): **espejo** de
|
|
3
|
+
* `backend/utils/projectConfig.js`, que es donde está explicado el porqué.
|
|
4
|
+
* El CLI no puede importar del backend; `backend/utils/__tests__/projectConfig.test.js`
|
|
5
|
+
* corre los dos sobre las mismas entradas para que no se separen.
|
|
6
|
+
*
|
|
7
|
+
* El CLI la lee de la carpeta local: los chequeos (MU-93) se corren antes de
|
|
8
|
+
* proponer, con lo que hay en disco.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
const CONFIG_PATH = 'shilo.config.json';
|
|
14
|
+
const UNIONES = ['archivo', 'lineas'];
|
|
15
|
+
const MAX_CHEQUEOS = 10;
|
|
16
|
+
const MAX_COMANDO = 500;
|
|
17
|
+
|
|
18
|
+
/** Los comandos válidos de `chequeos`, anotando en `avisos` lo que se ignora. */
|
|
19
|
+
function parseChequeos(raw, avisos) {
|
|
20
|
+
if (!Array.isArray(raw)) {
|
|
21
|
+
avisos.push('"chequeos" debe ser una lista de comandos');
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
const out = [];
|
|
25
|
+
for (const cmd of raw) {
|
|
26
|
+
if (typeof cmd !== 'string' || !cmd.trim() || cmd.length > MAX_COMANDO) {
|
|
27
|
+
avisos.push('Se ignoró un chequeo que no es un comando de texto');
|
|
28
|
+
} else if (out.length >= MAX_CHEQUEOS) {
|
|
29
|
+
avisos.push(`Más de ${MAX_CHEQUEOS} chequeos: se ignoran los demás`);
|
|
30
|
+
break;
|
|
31
|
+
} else {
|
|
32
|
+
out.push(cmd.trim());
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** El objeto de `shilo.config.json`, o null con el aviso de por qué no. */
|
|
39
|
+
function parseObject(text, avisos) {
|
|
40
|
+
let raw;
|
|
41
|
+
try {
|
|
42
|
+
const clean = String(text);
|
|
43
|
+
// Sin BOM: JSON.parse no lo acepta y algunos editores de Windows lo ponen.
|
|
44
|
+
raw = JSON.parse(clean.charCodeAt(0) === 0xfeff ? clean.slice(1) : clean);
|
|
45
|
+
} catch (err) {
|
|
46
|
+
avisos.push(`${CONFIG_PATH} no es JSON válido: se usan los valores por defecto`);
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
50
|
+
avisos.push(`${CONFIG_PATH} debe ser un objeto: se usan los valores por defecto`);
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
return raw;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @param {string|null|undefined} text - contenido de `shilo.config.json`
|
|
58
|
+
* @returns {{ union: 'archivo'|'lineas', chequeos: string[], avisos: string[] }}
|
|
59
|
+
*/
|
|
60
|
+
function parseProjectConfig(text) {
|
|
61
|
+
const config = { union: 'archivo', chequeos: [], avisos: [] };
|
|
62
|
+
if (text === null || text === undefined || String(text).trim() === '') return config;
|
|
63
|
+
|
|
64
|
+
const raw = parseObject(text, config.avisos);
|
|
65
|
+
if (!raw) return config;
|
|
66
|
+
|
|
67
|
+
if (raw.union !== undefined) {
|
|
68
|
+
if (UNIONES.includes(raw.union)) config.union = raw.union;
|
|
69
|
+
else config.avisos.push(`"union" debe ser ${UNIONES.join(' o ')}: se usa "archivo"`);
|
|
70
|
+
}
|
|
71
|
+
if (raw.chequeos !== undefined) config.chequeos = parseChequeos(raw.chequeos, config.avisos);
|
|
72
|
+
|
|
73
|
+
return config;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
module.exports = { CONFIG_PATH, UNIONES, MAX_CHEQUEOS, parseProjectConfig };
|