shilo-cli 0.3.2 → 0.6.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 +73 -1
- package/bin/shilo.js +32 -6
- package/package.json +1 -1
- package/src/ShiloClient.js +254 -8
- package/src/args.js +4 -2
- package/src/commands/init.js +16 -5
- package/src/commands/login.js +13 -2
- package/src/commands/pull.js +37 -9
- package/src/commands/push.js +841 -166
- package/src/prompt.js +10 -2
- package/src/pushindex.js +156 -0
- package/src/remote.js +2 -1
- package/src/secrets.js +168 -0
- package/src/sync.js +86 -10
- package/src/textguard.js +111 -30
- package/src/token.js +72 -0
- package/src/ui.js +61 -1
- package/src/walk.js +142 -34
package/src/commands/push.js
CHANGED
|
@@ -7,13 +7,33 @@ const remote = require('../remote');
|
|
|
7
7
|
const prompt = require('../prompt');
|
|
8
8
|
const ui = require('../ui');
|
|
9
9
|
const sync = require('../sync');
|
|
10
|
+
const secrets = require('../secrets');
|
|
11
|
+
const pushindex = require('../pushindex');
|
|
10
12
|
const { walk } = require('../walk');
|
|
11
|
-
const {
|
|
13
|
+
const {
|
|
14
|
+
classifyContent,
|
|
15
|
+
looksBinary,
|
|
16
|
+
binaryMimeType,
|
|
17
|
+
extensionOf,
|
|
18
|
+
BINARY_EXTENSIONS,
|
|
19
|
+
MAX_FILE_BYTES,
|
|
20
|
+
} = require('../textguard');
|
|
12
21
|
const { ShiloClient } = require('../ShiloClient');
|
|
13
22
|
|
|
14
23
|
const DEFAULT_BASE_URL = 'https://www.shilo.city';
|
|
15
24
|
const COMMIT_BATCH = 100; // máximo de archivos por commit (límite del backend)
|
|
16
25
|
|
|
26
|
+
// Cuánto se lee de cada archivo en el preflight: alcanza para decidir binario
|
|
27
|
+
// (looksBinary mira 8000 bytes) y para escanear secretos, sin cargar el repo
|
|
28
|
+
// entero en memoria antes de saber si hay algo para subir.
|
|
29
|
+
const HEAD_BYTES = 8192;
|
|
30
|
+
|
|
31
|
+
// Cuántos ejemplos se muestran por cada motivo de salteo.
|
|
32
|
+
const SKIP_EXAMPLES = 5;
|
|
33
|
+
// A partir de cuántos salteos en una misma carpeta de primer nivel sugerimos
|
|
34
|
+
// ignorarla entera.
|
|
35
|
+
const SUGGEST_MIN = 20;
|
|
36
|
+
|
|
17
37
|
// Mapa mínimo extensión -> mime para texto. El backend default es text/plain.
|
|
18
38
|
const TEXT_MIME = {
|
|
19
39
|
js: 'text/javascript', mjs: 'text/javascript', cjs: 'text/javascript',
|
|
@@ -30,8 +50,192 @@ function guessMime(name) {
|
|
|
30
50
|
return TEXT_MIME[ext] || 'text/plain';
|
|
31
51
|
}
|
|
32
52
|
|
|
53
|
+
const SKIP_LABELS = {
|
|
54
|
+
unreadable: 'no se pudieron leer',
|
|
55
|
+
symlink: 'symlinks (no soportados)',
|
|
56
|
+
name: 'nombre no permitido por Shilo',
|
|
57
|
+
depth: 'demasiado anidados',
|
|
58
|
+
// Los binarios ya NO se saltean: desde la Etapa 2 suben por URL firmada. Este
|
|
59
|
+
// motivo quedó sólo para lo que supera el techo de tamaño.
|
|
60
|
+
size: `mayores a ${MAX_FILE_BYTES / (1024 * 1024)} MB`,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** Lee como mucho `n` bytes del principio del archivo. */
|
|
64
|
+
function readHead(absPath, n) {
|
|
65
|
+
const fd = fs.openSync(absPath, 'r');
|
|
66
|
+
try {
|
|
67
|
+
const buf = Buffer.allocUnsafe(n);
|
|
68
|
+
const read = fs.readSync(fd, buf, 0, n, 0);
|
|
69
|
+
return buf.subarray(0, read);
|
|
70
|
+
} finally {
|
|
71
|
+
fs.closeSync(fd);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Fase offline: recorre, filtra y clasifica SIN tocar la red.
|
|
77
|
+
*
|
|
78
|
+
* Todo lo que puede hacer fracasar o cancelar un push (nombres inválidos,
|
|
79
|
+
* binarios, secretos, "no hay nada para subir") se decide acá, antes del primer
|
|
80
|
+
* request. Un push que iba a terminar en nada no debería costar ni una conexión.
|
|
81
|
+
*
|
|
82
|
+
* Sólo se leen los primeros KB de cada archivo; el contenido completo se lee
|
|
83
|
+
* recién cuando se sabe que ese archivo se sube.
|
|
84
|
+
*
|
|
85
|
+
* Desde la Etapa 2 esta fase ya no descarta binarios: los MARCA. Un archivo con
|
|
86
|
+
* `binary: true` sube por URL firmada (`client.uploadBinary`) en vez de por el
|
|
87
|
+
* lote JSON, porque el lote hashea el contenido como utf8 y ahí un binario se
|
|
88
|
+
* corrompe antes de que nadie pueda notarlo. Lo único que sigue siendo motivo de
|
|
89
|
+
* salteo es superar el techo de tamaño.
|
|
90
|
+
*/
|
|
91
|
+
function preflight(dir) {
|
|
92
|
+
const { files, skipped, ignored, gitDirs } = walk(dir);
|
|
93
|
+
|
|
94
|
+
const uploads = [];
|
|
95
|
+
const skips = skipped.slice();
|
|
96
|
+
const findings = [];
|
|
97
|
+
|
|
98
|
+
for (const f of files) {
|
|
99
|
+
const name = path.basename(f.relPath);
|
|
100
|
+
|
|
101
|
+
if (f.size > MAX_FILE_BYTES) {
|
|
102
|
+
skips.push({
|
|
103
|
+
relPath: f.relPath,
|
|
104
|
+
reason: `supera ${MAX_FILE_BYTES / (1024 * 1024)} MB (${f.size} bytes)`,
|
|
105
|
+
code: 'size',
|
|
106
|
+
});
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const ext = extensionOf(name);
|
|
111
|
+
let binary = Boolean(ext && BINARY_EXTENSIONS.has(ext));
|
|
112
|
+
let head = null;
|
|
113
|
+
|
|
114
|
+
// Un archivo cuya extensión ya dice que es binario no hace falta leerlo para
|
|
115
|
+
// saberlo, y leerlo sólo serviría para escanearle secretos — que en un JPEG
|
|
116
|
+
// son ruido.
|
|
117
|
+
if (!binary) {
|
|
118
|
+
try {
|
|
119
|
+
head = readHead(f.absPath, HEAD_BYTES);
|
|
120
|
+
} catch (e) {
|
|
121
|
+
skips.push({ relPath: f.relPath, reason: `no se pudo leer: ${e.message}`, code: 'unreadable' });
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
binary = looksBinary(head);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (!binary) {
|
|
128
|
+
findings.push(...secrets.inspect(f.relPath, head.toString('utf8')));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
uploads.push({
|
|
132
|
+
relPath: f.relPath,
|
|
133
|
+
absPath: f.absPath,
|
|
134
|
+
size: f.size,
|
|
135
|
+
mtimeMs: f.mtimeMs,
|
|
136
|
+
binary,
|
|
137
|
+
mimeType: binary ? binaryMimeType(name) : guessMime(f.relPath),
|
|
138
|
+
content: null,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return { uploads, skipped: skips, ignored, gitDirs, findings };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Reporte agrupado de salteos. Un repo con un `venv/` adentro generaba miles de
|
|
147
|
+
* líneas "saltado …" que tapaban todo lo demás; acá se resume por motivo, con
|
|
148
|
+
* unos pocos ejemplos, y se sugiere qué ignorar.
|
|
149
|
+
*/
|
|
150
|
+
function reportSkipped(skips, { verbose }) {
|
|
151
|
+
if (skips.length === 0) return;
|
|
152
|
+
|
|
153
|
+
if (verbose) {
|
|
154
|
+
for (const s of skips) ui.warn(`saltado ${ui.c.dim(s.relPath)} — ${s.reason}`);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const byCode = new Map();
|
|
159
|
+
for (const s of skips) {
|
|
160
|
+
const code = s.code || 'otros';
|
|
161
|
+
if (!byCode.has(code)) byCode.set(code, []);
|
|
162
|
+
byCode.get(code).push(s);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
ui.warn(`${skips.length} entrada(s) salteada(s):`);
|
|
166
|
+
for (const [code, list] of byCode) {
|
|
167
|
+
ui.info(` ${ui.c.dim('·')} ${list.length} ${SKIP_LABELS[code] || code}`);
|
|
168
|
+
for (const s of list.slice(0, SKIP_EXAMPLES)) {
|
|
169
|
+
ui.info(` ${ui.c.dim(s.relPath)} — ${s.reason}`);
|
|
170
|
+
}
|
|
171
|
+
if (list.length > SKIP_EXAMPLES) {
|
|
172
|
+
ui.info(ui.c.dim(` … y ${list.length - SKIP_EXAMPLES} más`));
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
suggestIgnore(skips);
|
|
177
|
+
ui.info(ui.c.dim('(--verbose muestra la lista completa)'));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Si los salteos se concentran en una carpeta de primer nivel, decirlo. */
|
|
181
|
+
function suggestIgnore(skips) {
|
|
182
|
+
const bySegment = new Map();
|
|
183
|
+
for (const s of skips) {
|
|
184
|
+
const seg = s.relPath.split('/')[0];
|
|
185
|
+
if (!seg || seg === s.relPath) continue; // sueltos en la raíz: no hay carpeta que ignorar
|
|
186
|
+
bySegment.set(seg, (bySegment.get(seg) || 0) + 1);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const worst = [...bySegment.entries()]
|
|
190
|
+
.filter(([, n]) => n >= SUGGEST_MIN)
|
|
191
|
+
.sort((a, b) => b[1] - a[1])
|
|
192
|
+
.slice(0, 3);
|
|
193
|
+
if (worst.length === 0) return;
|
|
194
|
+
|
|
195
|
+
ui.info('');
|
|
196
|
+
ui.info('Si esas carpetas no van al repo, agregalas a .shiloignore:');
|
|
197
|
+
for (const [seg, n] of worst) {
|
|
198
|
+
ui.info(` ${ui.c.bold(`${seg}/`)} ${ui.c.dim(`(${n} salteados)`)}`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Muestra los hallazgos de secretos. Nunca imprime el valor detectado. */
|
|
203
|
+
function reportSecrets(findings) {
|
|
204
|
+
const groups = secrets.groupByFile(findings);
|
|
205
|
+
ui.error(`Posibles secretos en ${groups.length} archivo(s):`);
|
|
206
|
+
for (const g of groups) {
|
|
207
|
+
if (g.name) {
|
|
208
|
+
ui.info(` ${ui.c.red('!')} ${g.relPath} ${ui.c.dim(`(coincide con "${g.name}")`)}`);
|
|
209
|
+
}
|
|
210
|
+
for (const hit of g.content) {
|
|
211
|
+
ui.info(` ${ui.c.red('!')} ${g.relPath}:${hit.line} ${ui.c.dim(`— parece ${hit.label}`)}`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
ui.info('');
|
|
215
|
+
ui.info('No se subió nada. Opciones:');
|
|
216
|
+
ui.info(' · agregar esos archivos a .shiloignore (lo más común)');
|
|
217
|
+
ui.info(' · sacar el secreto del contenido y volver a intentar');
|
|
218
|
+
ui.info(' · si es un falso positivo, repetir con --allow-secrets');
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Todas las carpetas ancestro de un conjunto de rutas de archivo. */
|
|
222
|
+
function neededFolders(relPaths) {
|
|
223
|
+
const set = new Set();
|
|
224
|
+
for (const p of relPaths) {
|
|
225
|
+
const parts = p.split('/');
|
|
226
|
+
parts.pop(); // el nombre del archivo
|
|
227
|
+
let acc = '';
|
|
228
|
+
for (const part of parts) {
|
|
229
|
+
acc = acc ? `${acc}/${part}` : part;
|
|
230
|
+
set.add(acc);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return set;
|
|
234
|
+
}
|
|
235
|
+
|
|
33
236
|
/**
|
|
34
|
-
* shilo push [carpeta] [--project <uuid>] [-m <msg>] [--dry-run] [--yes]
|
|
237
|
+
* shilo push [carpeta] [--project <uuid>] [-m <msg>] [--dry-run] [--yes]
|
|
238
|
+
* [--allow-secrets] [--concurrency <n>] [--no-index] [--verbose]
|
|
35
239
|
*
|
|
36
240
|
* Replica una carpeta local a un repo de Shilo: crea archivos nuevos, actualiza
|
|
37
241
|
* los que cambiaron, salta binarios/oversize/nombres inválidos, y registra todo
|
|
@@ -44,6 +248,8 @@ async function push(args) {
|
|
|
44
248
|
return 1;
|
|
45
249
|
}
|
|
46
250
|
|
|
251
|
+
const verbose = !!args.verbose;
|
|
252
|
+
|
|
47
253
|
// 1) Resolver remote: --project pisa a .shilo.json.
|
|
48
254
|
const savedRemote = remote.read(dir);
|
|
49
255
|
const projectId =
|
|
@@ -77,108 +283,228 @@ async function push(args) {
|
|
|
77
283
|
return 1;
|
|
78
284
|
}
|
|
79
285
|
|
|
80
|
-
// 3)
|
|
81
|
-
const {
|
|
286
|
+
// 3) PREFLIGHT: todo offline. Recién después de esto se toca la red.
|
|
287
|
+
const { uploads, skipped, ignored, gitDirs, findings } = preflight(dir);
|
|
82
288
|
|
|
83
|
-
if (
|
|
84
|
-
|
|
85
|
-
|
|
289
|
+
if (gitDirs.length > 0) {
|
|
290
|
+
ui.warn(
|
|
291
|
+
`${gitDirs.length} repositorio(s) git podado(s) (traen todo el historial, ` +
|
|
292
|
+
`incluidos secretos ya borrados del árbol de trabajo):`
|
|
293
|
+
);
|
|
294
|
+
for (const g of gitDirs.slice(0, SKIP_EXAMPLES)) ui.info(` ${ui.c.dim(g)}`);
|
|
295
|
+
if (gitDirs.length > SKIP_EXAMPLES) {
|
|
296
|
+
ui.info(ui.c.dim(` … y ${gitDirs.length - SKIP_EXAMPLES} más`));
|
|
86
297
|
}
|
|
87
298
|
}
|
|
88
299
|
|
|
89
|
-
|
|
90
|
-
const uploads = []; // { relPath, absPath, content, mimeType }
|
|
91
|
-
const skippedBinary = [];
|
|
92
|
-
for (const f of files) {
|
|
93
|
-
let buf;
|
|
94
|
-
try {
|
|
95
|
-
buf = fs.readFileSync(f.absPath);
|
|
96
|
-
} catch (e) {
|
|
97
|
-
skippedBinary.push({ relPath: f.relPath, reason: `no se pudo leer: ${e.message}` });
|
|
98
|
-
continue;
|
|
99
|
-
}
|
|
100
|
-
const cls = classifyContent(path.basename(f.relPath), buf);
|
|
101
|
-
if (!cls.isText) {
|
|
102
|
-
skippedBinary.push({ relPath: f.relPath, reason: cls.reason });
|
|
103
|
-
continue;
|
|
104
|
-
}
|
|
105
|
-
uploads.push({
|
|
106
|
-
relPath: f.relPath,
|
|
107
|
-
absPath: f.absPath,
|
|
108
|
-
content: cls.content,
|
|
109
|
-
mimeType: guessMime(f.relPath),
|
|
110
|
-
});
|
|
111
|
-
}
|
|
300
|
+
reportSkipped(skipped, { verbose });
|
|
112
301
|
|
|
113
|
-
|
|
114
|
-
ui.
|
|
302
|
+
if (findings.length > 0 && !args['allow-secrets']) {
|
|
303
|
+
ui.info('');
|
|
304
|
+
reportSecrets(findings);
|
|
305
|
+
return 1;
|
|
306
|
+
}
|
|
307
|
+
if (findings.length > 0) {
|
|
308
|
+
ui.warn(
|
|
309
|
+
`${secrets.groupByFile(findings).length} archivo(s) con posibles secretos ` +
|
|
310
|
+
`se van a subir igual (--allow-secrets).`
|
|
311
|
+
);
|
|
115
312
|
}
|
|
116
313
|
|
|
117
314
|
if (uploads.length === 0) {
|
|
118
|
-
ui.info('No hay archivos
|
|
119
|
-
ui.info(
|
|
120
|
-
ui.c.dim(
|
|
121
|
-
`(${ignored} ignorados, ${skipped.length + skippedBinary.length} saltados)`
|
|
122
|
-
)
|
|
123
|
-
);
|
|
315
|
+
ui.info('No hay archivos para subir.');
|
|
316
|
+
ui.info(ui.c.dim(`(${ignored} ignorados, ${skipped.length} salteados)`));
|
|
124
317
|
return 0;
|
|
125
318
|
}
|
|
126
319
|
|
|
320
|
+
// 4) Cliente + control de ritmo. `onThrottle` baja la concurrencia sola si el
|
|
321
|
+
// servidor empieza a responder 429, en vez de insistir hasta que corte.
|
|
322
|
+
const limit = sync.resolveConcurrency(args);
|
|
323
|
+
const throttle = sync.makeThrottle(limit);
|
|
127
324
|
const client = new ShiloClient({
|
|
128
325
|
baseURL,
|
|
129
326
|
supabaseUrl: cfg.supabaseUrl || null,
|
|
130
327
|
supabaseAnonKey: cfg.supabaseAnonKey || null,
|
|
328
|
+
onThrottle: () => {
|
|
329
|
+
if (throttle.reduce()) {
|
|
330
|
+
ui.warn('El servidor está limitando el ritmo (429): bajando la concurrencia.');
|
|
331
|
+
}
|
|
332
|
+
},
|
|
131
333
|
});
|
|
132
334
|
|
|
133
335
|
ui.info(
|
|
134
336
|
`Proyecto ${ui.c.bold(projectId)} en ${ui.c.cyan(baseURL)} — ` +
|
|
135
|
-
`${uploads.length} archivo(s)
|
|
136
|
-
`${
|
|
337
|
+
`${uploads.length} archivo(s) (${uploads.filter((u) => u.binary).length} binario(s)), ` +
|
|
338
|
+
`${ignored} ignorado(s), ` +
|
|
339
|
+
`${skipped.length} salteado(s).`
|
|
137
340
|
);
|
|
138
341
|
|
|
139
|
-
// 5)
|
|
140
|
-
//
|
|
141
|
-
// Se hace con un pool de concurrencia (mismo patrón que walkRemote en
|
|
142
|
-
// pull/status) en vez de un archivo a la vez: es lo que hacía que un push
|
|
143
|
-
// de muchos archivos tardara minutos por puro round-trip secuencial.
|
|
144
|
-
// `mgr` serializa el refresh del JWT para que llamadas paralelas no
|
|
145
|
-
// quemen el mismo refresh_token.
|
|
342
|
+
// 5) Planificación. `mgr` serializa el refresh del JWT para que las llamadas
|
|
343
|
+
// paralelas no quemen el mismo refresh_token.
|
|
146
344
|
const mgr = sync.makeCredsManager(client, cfg);
|
|
147
345
|
const plan = { added: [], modified: [], unchanged: [], failed: [] };
|
|
148
346
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
347
|
+
// 5a) Árbol remoto completo en un solo recorrido (un request por CARPETA).
|
|
348
|
+
// Antes esto era un getFileMeta por ARCHIVO: para 142 archivos en 30
|
|
349
|
+
// carpetas, 142 requests pasan a 30.
|
|
350
|
+
let tree;
|
|
351
|
+
try {
|
|
352
|
+
tree = await sync.walkRemote(client, projectId, mgr, { concurrency: throttle });
|
|
353
|
+
} catch (e) {
|
|
354
|
+
ui.error(`No se pudo leer el estado del proyecto: ${e.message}`);
|
|
355
|
+
return 1;
|
|
356
|
+
}
|
|
357
|
+
config.persistCredsIfChanged(cfg, mgr.current);
|
|
358
|
+
|
|
359
|
+
const treeComplete = tree.errors.length === 0;
|
|
360
|
+
if (!treeComplete) {
|
|
361
|
+
ui.warn(
|
|
362
|
+
`No se pudieron listar ${tree.errors.length} carpeta(s) del proyecto: ` +
|
|
363
|
+
`la planificación puede quedar incompleta.`
|
|
364
|
+
);
|
|
365
|
+
if (verbose) {
|
|
366
|
+
for (const e of tree.errors) ui.info(` ${ui.c.dim(e.folderPath || '/')} — ${e.reason}`);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const remoteByPath = new Map(tree.files.map((f) => [f.repoPath, f]));
|
|
371
|
+
|
|
372
|
+
// 5b) Índice local: un archivo cuyo size+mtime son los del último push exitoso
|
|
373
|
+
// no cambió, así que si además el remoto tiene ese mismo tamaño, no hace
|
|
374
|
+
// falta bajar contenido para compararlo. Es lo que hace que un push sin
|
|
375
|
+
// cambios sea casi gratis.
|
|
376
|
+
const useIndex = !args['no-index'];
|
|
377
|
+
const index = useIndex
|
|
378
|
+
? pushindex.read(dir, { projectId, baseURL })
|
|
379
|
+
: { version: pushindex.INDEX_VERSION, projectId, baseURL, entries: {} };
|
|
152
380
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
381
|
+
const toCompare = []; // mismo tamaño, hay que mirar el contenido
|
|
382
|
+
const toResolve = []; // distinto tamaño: sólo falta su storage_path
|
|
383
|
+
|
|
384
|
+
for (const u of uploads) {
|
|
385
|
+
if (tree.folders.has(u.relPath)) {
|
|
386
|
+
plan.failed.push({ ...u, reason: 'ya existe una carpeta con ese nombre' });
|
|
387
|
+
continue;
|
|
156
388
|
}
|
|
157
389
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
390
|
+
const r = remoteByPath.get(u.relPath);
|
|
391
|
+
if (!r) {
|
|
392
|
+
plan.added.push(u);
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Servidor viejo: el listado no trae storage_path, y el commit lo exige para
|
|
397
|
+
// las modificaciones. Se resuelve con un getFileMeta, como antes.
|
|
398
|
+
if (!r.storage_path) {
|
|
399
|
+
if (typeof r.size === 'number' && r.size !== u.size) {
|
|
400
|
+
toResolve.push({ ...u, remote: r });
|
|
401
|
+
} else if (useIndex && pushindex.isFresh(index, u).fresh) {
|
|
402
|
+
plan.unchanged.push(u);
|
|
403
|
+
} else {
|
|
404
|
+
toCompare.push({ ...u, remote: r });
|
|
165
405
|
}
|
|
406
|
+
continue;
|
|
166
407
|
}
|
|
167
|
-
return { kind: 'modified', u: { ...u, remote: meta.file } };
|
|
168
|
-
});
|
|
169
408
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
409
|
+
// Camino rápido: el remoto publica el SHA-256 de su contenido, así que
|
|
410
|
+
// "¿cambió?" se responde con aritmética local. Cero requests, cero bytes
|
|
411
|
+
// bajados — antes esto era descargar el archivo entero para comparar.
|
|
412
|
+
if (r.content_hash) {
|
|
413
|
+
// Mejor aún: si el índice dice que el archivo local está intacto y su hash
|
|
414
|
+
// ya coincide con el del servidor, ni siquiera hace falta leerlo del disco.
|
|
415
|
+
if (useIndex) {
|
|
416
|
+
const { fresh, entry } = pushindex.isFresh(index, u);
|
|
417
|
+
if (fresh && entry && entry.hash === r.content_hash) {
|
|
418
|
+
plan.unchanged.push({ ...u, remote: r });
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
let content;
|
|
424
|
+
try {
|
|
425
|
+
content = await loadContent(u);
|
|
426
|
+
} catch (e) {
|
|
427
|
+
plan.failed.push({ ...u, reason: e.message });
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
if (pushindex.hashContent(content) === r.content_hash) {
|
|
432
|
+
plan.unchanged.push({ ...u, content, remote: r });
|
|
433
|
+
} else {
|
|
434
|
+
plan.modified.push({ ...u, content, remote: r });
|
|
435
|
+
}
|
|
173
436
|
continue;
|
|
174
437
|
}
|
|
175
|
-
|
|
176
|
-
|
|
438
|
+
|
|
439
|
+
// Archivo anterior a la migración 010: no tiene hash. Camino viejo, pero ya
|
|
440
|
+
// sin el request extra para el storage_path.
|
|
441
|
+
if (typeof r.size === 'number' && r.size !== u.size) {
|
|
442
|
+
plan.modified.push({ ...u, remote: r });
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
if (useIndex && pushindex.isFresh(index, u).fresh) {
|
|
447
|
+
plan.unchanged.push(u);
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
toCompare.push({ ...u, remote: r });
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// 5c) Los que quedaron: un request cada uno, pero sólo por los candidatos a
|
|
454
|
+
// cambio, no por todo el repo.
|
|
455
|
+
const pending = toCompare.concat(toResolve);
|
|
456
|
+
if (pending.length > 0) {
|
|
457
|
+
const prog = ui.progress(pending.length, 'comparando');
|
|
458
|
+
const results = await sync.runPool(
|
|
459
|
+
pending,
|
|
460
|
+
throttle,
|
|
461
|
+
async (u) => {
|
|
462
|
+
// Distinto tamaño: ya sabemos que cambió, sólo hace falta la metadata
|
|
463
|
+
// (el commit exige el storage_path anterior).
|
|
464
|
+
if (typeof u.remote.size === 'number' && u.remote.size !== u.size) {
|
|
465
|
+
const creds = await mgr.valid();
|
|
466
|
+
const meta = await client.getFileMeta(projectId, u.relPath, creds);
|
|
467
|
+
if (!meta.exists) return { kind: 'added', u };
|
|
468
|
+
return { kind: 'modified', u: { ...u, remote: meta.file } };
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const content = await loadContent(u);
|
|
472
|
+
|
|
473
|
+
// Un binario no se compara bajando el contenido: el GET devuelve una URL
|
|
474
|
+
// firmada, no bytes, justamente para no arrastrar el archivo por el
|
|
475
|
+
// payload de la función. Sin `content_hash` remoto no queda con qué
|
|
476
|
+
// comparar, así que se asume modificado. El servidor deduplica: si el
|
|
477
|
+
// contenido es el mismo, el blob ya existe y no se transfiere un byte.
|
|
478
|
+
if (u.binary) {
|
|
479
|
+
const creds = await mgr.valid();
|
|
480
|
+
const meta = await client.getFileMeta(projectId, u.relPath, creds);
|
|
481
|
+
if (!meta.exists) return { kind: 'added', u: { ...u, content } };
|
|
482
|
+
return { kind: 'modified', u: { ...u, content, remote: meta.file } };
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const creds = await mgr.valid();
|
|
486
|
+
const read = await client.readFile(projectId, u.relPath, creds);
|
|
487
|
+
if (read.file && read.file.content === content) {
|
|
488
|
+
return { kind: 'unchanged', u: { ...u, content, remote: read.file } };
|
|
489
|
+
}
|
|
490
|
+
return { kind: 'modified', u: { ...u, content, remote: read.file } };
|
|
491
|
+
},
|
|
492
|
+
{ onProgress: (done) => prog.update(done) }
|
|
493
|
+
);
|
|
494
|
+
prog.clear();
|
|
495
|
+
|
|
496
|
+
for (const r of results) {
|
|
497
|
+
if (r && r.__error) {
|
|
498
|
+
plan.failed.push({ ...r.item, reason: r.__error });
|
|
499
|
+
continue;
|
|
500
|
+
}
|
|
501
|
+
plan[r.kind].push(r.u);
|
|
502
|
+
}
|
|
177
503
|
}
|
|
178
504
|
|
|
179
505
|
config.persistCredsIfChanged(cfg, mgr.current);
|
|
180
506
|
|
|
181
|
-
//
|
|
507
|
+
// 5d) Atribución: para los archivos que vamos a pisar, quién los tocó último.
|
|
182
508
|
// Es lo que distingue "esto lo subí yo antes" de "esto lo va a perder un
|
|
183
509
|
// compañero". Best-effort: si el historial no se puede leer, seguimos.
|
|
184
510
|
let attribution = { index: new Map() };
|
|
@@ -219,6 +545,7 @@ async function push(args) {
|
|
|
219
545
|
for (const f of plan.failed) ui.error(`${f.relPath} — ${f.reason}`);
|
|
220
546
|
return 1;
|
|
221
547
|
}
|
|
548
|
+
if (useIndex && !dryRun) saveIndex(dir, index, plan, uploads);
|
|
222
549
|
ui.success('Todo al día. Nada para subir.');
|
|
223
550
|
return 0;
|
|
224
551
|
}
|
|
@@ -265,142 +592,490 @@ async function push(args) {
|
|
|
265
592
|
}
|
|
266
593
|
}
|
|
267
594
|
|
|
268
|
-
// 8) Ejecutar subidas.
|
|
269
|
-
|
|
270
|
-
|
|
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.
|
|
271
598
|
const failedWrites = [];
|
|
599
|
+
const pushedAt = Date.now();
|
|
600
|
+
let committed = 0;
|
|
601
|
+
let commitCount = 0;
|
|
272
602
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
for (const u of plan.added) {
|
|
281
|
-
try {
|
|
282
|
-
const creds = await mgr.valid();
|
|
283
|
-
await client.ensurePath(projectId, u.relPath, creds, ensuredCache);
|
|
284
|
-
} catch (e) {
|
|
285
|
-
ensureFailed.add(u.relPath);
|
|
286
|
-
failedWrites.push({ relPath: u.relPath, reason: e.message });
|
|
287
|
-
ui.error(`${u.relPath} — ${e.message}`);
|
|
603
|
+
/** Log por archivo: en TTY lo tapa la barra de progreso, así que va sólo con --verbose. */
|
|
604
|
+
const logFile = (prog, msg) => {
|
|
605
|
+
if (verbose) {
|
|
606
|
+
prog.clear();
|
|
607
|
+
ui.success(msg);
|
|
608
|
+
} else {
|
|
609
|
+
prog.line(`✓ ${msg}`);
|
|
288
610
|
}
|
|
611
|
+
};
|
|
612
|
+
const logError = (prog, msg) => {
|
|
613
|
+
prog.clear();
|
|
614
|
+
ui.error(msg);
|
|
615
|
+
};
|
|
616
|
+
|
|
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.
|
|
622
|
+
//
|
|
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)
|
|
636
|
+
);
|
|
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
|
+
}
|
|
643
|
+
|
|
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
|
+
}
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
config.persistCredsIfChanged(cfg, mgr.current);
|
|
289
674
|
}
|
|
290
|
-
config.persistCredsIfChanged(cfg, mgr.current);
|
|
291
675
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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)]) {
|
|
707
|
+
let current = [];
|
|
708
|
+
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;
|
|
711
|
+
const full =
|
|
712
|
+
current.length >= BATCH_MAX_FILES || currentBytes + bytes > BATCH_MAX_BYTES;
|
|
713
|
+
if (current.length > 0 && full) {
|
|
714
|
+
batches.push(current);
|
|
715
|
+
current = [];
|
|
716
|
+
currentBytes = 0;
|
|
717
|
+
}
|
|
718
|
+
current.push(e);
|
|
719
|
+
currentBytes += bytes;
|
|
720
|
+
}
|
|
721
|
+
if (current.length > 0) batches.push(current);
|
|
722
|
+
}
|
|
723
|
+
|
|
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
|
+
};
|
|
730
|
+
|
|
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
|
+
}));
|
|
297
741
|
const creds = await mgr.valid();
|
|
298
|
-
const
|
|
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;
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
return { ok: true, change, symbol: action === 'added' ? '+' : '~' };
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
|
|
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
|
|
791
|
+
);
|
|
792
|
+
|
|
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;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
return { ok: true, change, symbol: action === 'added' ? '+' : '~' };
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
return results.map((r) => (r && r.__error ? { ok: false, reason: r.__error } : r));
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/** Camino archivo-por-archivo: server sin /batch, o archivo demasiado grande. */
|
|
815
|
+
async function writeViaSingle(entries) {
|
|
816
|
+
await ensureFolders();
|
|
299
817
|
|
|
300
|
-
|
|
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' };
|
|
823
|
+
} else {
|
|
824
|
+
queue.push({ e, i });
|
|
825
|
+
}
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
const results = await sync.runPool(queue, throttle, async ({ e }) => {
|
|
829
|
+
const u = e.u;
|
|
830
|
+
if (e.isNew) {
|
|
831
|
+
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) {
|
|
834
|
+
return {
|
|
835
|
+
ok: true,
|
|
836
|
+
symbol: '+',
|
|
837
|
+
change: {
|
|
838
|
+
file_id: created.file.id,
|
|
839
|
+
action: 'added',
|
|
840
|
+
new_storage_path: created.file.storage_path,
|
|
841
|
+
},
|
|
842
|
+
};
|
|
843
|
+
}
|
|
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);
|
|
847
|
+
return {
|
|
848
|
+
ok: true,
|
|
849
|
+
symbol: '~',
|
|
850
|
+
change: {
|
|
851
|
+
file_id: upd.file.id,
|
|
852
|
+
action: 'modified',
|
|
853
|
+
previous_storage_path:
|
|
854
|
+
(created.file && created.file.storage_path) || upd.file.storage_path,
|
|
855
|
+
new_storage_path: upd.file.storage_path,
|
|
856
|
+
},
|
|
857
|
+
};
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
const creds = await mgr.valid();
|
|
861
|
+
const upd = await client.writeFile(projectId, u.relPath, e.content, u.mimeType, creds);
|
|
301
862
|
return {
|
|
302
|
-
|
|
303
|
-
symbol: '
|
|
863
|
+
ok: true,
|
|
864
|
+
symbol: '~',
|
|
304
865
|
change: {
|
|
305
|
-
file_id:
|
|
306
|
-
action: '
|
|
307
|
-
|
|
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
|
+
: {}),
|
|
308
874
|
},
|
|
309
875
|
};
|
|
310
|
-
}
|
|
311
|
-
// 409: apareció entremedio; caemos a update.
|
|
312
|
-
const writeCreds = await mgr.valid();
|
|
313
|
-
const upd = await client.writeFile(projectId, u.relPath, u.content, u.mimeType, writeCreds);
|
|
314
|
-
return {
|
|
315
|
-
u,
|
|
316
|
-
symbol: '~',
|
|
317
|
-
change: {
|
|
318
|
-
file_id: upd.file.id,
|
|
319
|
-
action: 'modified',
|
|
320
|
-
previous_storage_path: (created.file && created.file.storage_path) || upd.file.storage_path,
|
|
321
|
-
new_storage_path: upd.file.storage_path,
|
|
322
|
-
},
|
|
323
|
-
};
|
|
324
|
-
});
|
|
876
|
+
});
|
|
325
877
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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;
|
|
331
883
|
}
|
|
332
|
-
|
|
333
|
-
ui.success(`${ui.c[r.symbol === '+' ? 'green' : 'yellow'](r.symbol)} ${r.u.relPath}`);
|
|
884
|
+
return outcomes;
|
|
334
885
|
}
|
|
335
|
-
config.persistCredsIfChanged(cfg, mgr.current);
|
|
336
886
|
|
|
337
|
-
|
|
338
|
-
const
|
|
339
|
-
const upd = await client.writeFile(projectId, u.relPath, u.content, u.mimeType, creds);
|
|
340
|
-
return {
|
|
341
|
-
u,
|
|
342
|
-
change: {
|
|
343
|
-
file_id: upd.file.id,
|
|
344
|
-
action: 'modified',
|
|
345
|
-
previous_storage_path: u.remote ? u.remote.storage_path : undefined,
|
|
346
|
-
new_storage_path: upd.file.storage_path,
|
|
347
|
-
},
|
|
348
|
-
};
|
|
349
|
-
});
|
|
887
|
+
for (let bi = 0; bi < batches.length; bi += 1) {
|
|
888
|
+
const entries = batches[bi];
|
|
350
889
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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();
|
|
901
|
+
}
|
|
356
902
|
}
|
|
357
|
-
|
|
358
|
-
ui.success(`${ui.c.yellow('~')} ${r.u.relPath}`);
|
|
359
|
-
}
|
|
360
|
-
config.persistCredsIfChanged(cfg, mgr.current);
|
|
903
|
+
if (loaded.length === 0) continue;
|
|
361
904
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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]));
|
|
920
|
+
}
|
|
921
|
+
|
|
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;
|
|
926
|
+
|
|
927
|
+
if (batchSupported && !oversize) {
|
|
928
|
+
try {
|
|
929
|
+
res = await writeViaBatch(texts);
|
|
930
|
+
} 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
|
+
}
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
if (!res) res = await writeViaSingle(texts);
|
|
945
|
+
texts.forEach((e, i) => outcomeByEntry.set(e, res[i]));
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
const outcomes = loaded.map((e) => outcomeByEntry.get(e));
|
|
949
|
+
config.persistCredsIfChanged(cfg, mgr.current);
|
|
950
|
+
|
|
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;
|
|
960
|
+
}
|
|
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
|
+
);
|
|
371
981
|
try {
|
|
372
982
|
const creds = await mgr.valid();
|
|
373
|
-
await client.createCommit(projectId, msg,
|
|
374
|
-
committed +=
|
|
983
|
+
await client.createCommit(projectId, msg, batchChanges, creds);
|
|
984
|
+
committed += batchChanges.length;
|
|
985
|
+
commitCount += 1;
|
|
375
986
|
config.persistCredsIfChanged(cfg, mgr.current);
|
|
376
987
|
} catch (e) {
|
|
377
|
-
ui.error(`Fallo al crear el commit (lote ${partNo}/${
|
|
988
|
+
ui.error(`Fallo al crear el commit (lote ${partNo}/${batches.length}): ${e.message}`);
|
|
378
989
|
failedWrites.push({ relPath: `<commit lote ${partNo}>`, reason: e.message });
|
|
379
990
|
}
|
|
380
991
|
}
|
|
992
|
+
|
|
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;
|
|
998
|
+
}
|
|
381
999
|
}
|
|
1000
|
+
upProg.clear();
|
|
382
1001
|
|
|
383
1002
|
config.persistCredsIfChanged(cfg, mgr.current);
|
|
384
1003
|
|
|
385
|
-
// 10)
|
|
386
|
-
//
|
|
387
|
-
if (
|
|
1004
|
+
// 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.
|
|
1006
|
+
if (useIndex) {
|
|
1007
|
+
for (const u of uploaded) {
|
|
1008
|
+
pushindex.record(index, u, pushedAt);
|
|
1009
|
+
}
|
|
1010
|
+
saveIndex(dir, index, plan, uploads, pushedAt);
|
|
1011
|
+
}
|
|
1012
|
+
|
|
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) {
|
|
388
1017
|
remote.touchSync(dir, { projectId, baseURL });
|
|
389
1018
|
}
|
|
390
1019
|
|
|
391
|
-
//
|
|
1020
|
+
// 12) Resumen final.
|
|
392
1021
|
ui.info('');
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
ui.success(
|
|
396
|
-
`Push completo: ${committed} archivo(s) en ${nCommits} commit(s).`
|
|
397
|
-
);
|
|
1022
|
+
if (failedWrites.length === 0 && plan.failed.length === 0) {
|
|
1023
|
+
ui.success(`Push completo: ${committed} archivo(s) en ${commitCount} commit(s).`);
|
|
398
1024
|
return 0;
|
|
399
1025
|
}
|
|
400
|
-
ui.
|
|
1026
|
+
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
|
+
);
|
|
401
1030
|
return 1;
|
|
402
1031
|
}
|
|
403
1032
|
|
|
1033
|
+
/**
|
|
1034
|
+
* Lee el contenido completo de un archivo, cacheado en la propia entrada.
|
|
1035
|
+
*
|
|
1036
|
+
* Devuelve un `string` para texto y un `Buffer` para binarios. Esa diferencia
|
|
1037
|
+
* es la que decide por qué camino sube el archivo, así que se re-clasifica acá y
|
|
1038
|
+
* no se confía en lo que dijo el preflight: entre una cosa y la otra el archivo
|
|
1039
|
+
* pudo cambiar, y el preflight sólo miró los primeros 8 KB.
|
|
1040
|
+
*
|
|
1041
|
+
* `u.binary` y `u.mimeType` se corrigen con lo que digan los bytes completos.
|
|
1042
|
+
* Un archivo que el preflight leyó como texto y resultó binario tiene que
|
|
1043
|
+
* cambiar de camino, no subirse mal por el que ya tenía asignado.
|
|
1044
|
+
*/
|
|
1045
|
+
async function loadContent(u) {
|
|
1046
|
+
if (u.content != null) return u.content;
|
|
1047
|
+
|
|
1048
|
+
const buf = await fs.promises.readFile(u.absPath);
|
|
1049
|
+
const cls = classifyContent(path.basename(u.relPath), buf);
|
|
1050
|
+
|
|
1051
|
+
if (cls.isBinary) {
|
|
1052
|
+
u.binary = true;
|
|
1053
|
+
u.mimeType = cls.mimeType;
|
|
1054
|
+
u.content = cls.buffer;
|
|
1055
|
+
return u.content;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
if (!cls.isText) throw new Error(cls.reason);
|
|
1059
|
+
|
|
1060
|
+
u.binary = false;
|
|
1061
|
+
u.content = cls.content;
|
|
1062
|
+
return u.content;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
/**
|
|
1066
|
+
* Registra en el índice los archivos que ya estaban idénticos (para no volver a
|
|
1067
|
+
* compararlos), saca los que ya no existen y lo escribe.
|
|
1068
|
+
*/
|
|
1069
|
+
function saveIndex(dir, index, plan, uploads, pushedAt = Date.now()) {
|
|
1070
|
+
for (const u of plan.unchanged) {
|
|
1071
|
+
if (u.content != null) {
|
|
1072
|
+
pushindex.record(index, { ...u, hash: pushindex.hashContent(u.content) }, pushedAt);
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
pushindex.prune(index, new Set(uploads.map((u) => u.relPath)));
|
|
1076
|
+
pushindex.write(dir, index);
|
|
1077
|
+
}
|
|
1078
|
+
|
|
404
1079
|
/**
|
|
405
1080
|
* Arma el mensaje del commit. Si vino `--card`, se agrega como trailer
|
|
406
1081
|
* `[card: <id>]` al final: queda visible en el historial y es fácil de grepear
|