shilo-cli 0.3.2 → 0.5.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 +11 -4
- package/package.json +1 -1
- package/src/ShiloClient.js +121 -5
- package/src/args.js +4 -2
- package/src/commands/init.js +16 -5
- package/src/commands/login.js +13 -2
- package/src/commands/push.js +711 -164
- package/src/prompt.js +10 -2
- package/src/pushindex.js +151 -0
- package/src/remote.js +2 -1
- package/src/secrets.js +168 -0
- package/src/sync.js +86 -10
- package/src/textguard.js +44 -14
- 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,32 @@ 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
|
+
extensionOf,
|
|
17
|
+
BINARY_EXTENSIONS,
|
|
18
|
+
MAX_FILE_BYTES,
|
|
19
|
+
} = require('../textguard');
|
|
12
20
|
const { ShiloClient } = require('../ShiloClient');
|
|
13
21
|
|
|
14
22
|
const DEFAULT_BASE_URL = 'https://www.shilo.city';
|
|
15
23
|
const COMMIT_BATCH = 100; // máximo de archivos por commit (límite del backend)
|
|
16
24
|
|
|
25
|
+
// Cuánto se lee de cada archivo en el preflight: alcanza para decidir binario
|
|
26
|
+
// (looksBinary mira 8000 bytes) y para escanear secretos, sin cargar el repo
|
|
27
|
+
// entero en memoria antes de saber si hay algo para subir.
|
|
28
|
+
const HEAD_BYTES = 8192;
|
|
29
|
+
|
|
30
|
+
// Cuántos ejemplos se muestran por cada motivo de salteo.
|
|
31
|
+
const SKIP_EXAMPLES = 5;
|
|
32
|
+
// A partir de cuántos salteos en una misma carpeta de primer nivel sugerimos
|
|
33
|
+
// ignorarla entera.
|
|
34
|
+
const SUGGEST_MIN = 20;
|
|
35
|
+
|
|
17
36
|
// Mapa mínimo extensión -> mime para texto. El backend default es text/plain.
|
|
18
37
|
const TEXT_MIME = {
|
|
19
38
|
js: 'text/javascript', mjs: 'text/javascript', cjs: 'text/javascript',
|
|
@@ -30,8 +49,182 @@ function guessMime(name) {
|
|
|
30
49
|
return TEXT_MIME[ext] || 'text/plain';
|
|
31
50
|
}
|
|
32
51
|
|
|
52
|
+
const SKIP_LABELS = {
|
|
53
|
+
unreadable: 'no se pudieron leer',
|
|
54
|
+
symlink: 'symlinks (no soportados)',
|
|
55
|
+
name: 'nombre no permitido por Shilo',
|
|
56
|
+
depth: 'demasiado anidados',
|
|
57
|
+
binary: 'binarios o mayores a 25 MB',
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** Lee como mucho `n` bytes del principio del archivo. */
|
|
61
|
+
function readHead(absPath, n) {
|
|
62
|
+
const fd = fs.openSync(absPath, 'r');
|
|
63
|
+
try {
|
|
64
|
+
const buf = Buffer.allocUnsafe(n);
|
|
65
|
+
const read = fs.readSync(fd, buf, 0, n, 0);
|
|
66
|
+
return buf.subarray(0, read);
|
|
67
|
+
} finally {
|
|
68
|
+
fs.closeSync(fd);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
33
72
|
/**
|
|
34
|
-
*
|
|
73
|
+
* Fase offline: recorre, filtra y clasifica SIN tocar la red.
|
|
74
|
+
*
|
|
75
|
+
* Todo lo que puede hacer fracasar o cancelar un push (nombres inválidos,
|
|
76
|
+
* binarios, secretos, "no hay nada para subir") se decide acá, antes del primer
|
|
77
|
+
* request. Un push que iba a terminar en nada no debería costar ni una conexión.
|
|
78
|
+
*
|
|
79
|
+
* Sólo se leen los primeros KB de cada archivo; el contenido completo se lee
|
|
80
|
+
* recién cuando se sabe que ese archivo se sube.
|
|
81
|
+
*/
|
|
82
|
+
function preflight(dir) {
|
|
83
|
+
const { files, skipped, ignored, gitDirs } = walk(dir);
|
|
84
|
+
|
|
85
|
+
const uploads = [];
|
|
86
|
+
const skips = skipped.slice();
|
|
87
|
+
const findings = [];
|
|
88
|
+
|
|
89
|
+
for (const f of files) {
|
|
90
|
+
const name = path.basename(f.relPath);
|
|
91
|
+
|
|
92
|
+
const ext = extensionOf(name);
|
|
93
|
+
if (ext && BINARY_EXTENSIONS.has(ext)) {
|
|
94
|
+
skips.push({ relPath: f.relPath, reason: `extensión binaria .${ext}`, code: 'binary' });
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (f.size > MAX_FILE_BYTES) {
|
|
98
|
+
skips.push({ relPath: f.relPath, reason: `supera 25 MB (${f.size} bytes)`, code: 'binary' });
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
let head;
|
|
103
|
+
try {
|
|
104
|
+
head = readHead(f.absPath, HEAD_BYTES);
|
|
105
|
+
} catch (e) {
|
|
106
|
+
skips.push({ relPath: f.relPath, reason: `no se pudo leer: ${e.message}`, code: 'unreadable' });
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (looksBinary(head)) {
|
|
111
|
+
skips.push({
|
|
112
|
+
relPath: f.relPath,
|
|
113
|
+
reason: 'contenido binario (bytes no imprimibles)',
|
|
114
|
+
code: 'binary',
|
|
115
|
+
});
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
findings.push(...secrets.inspect(f.relPath, head.toString('utf8')));
|
|
120
|
+
|
|
121
|
+
uploads.push({
|
|
122
|
+
relPath: f.relPath,
|
|
123
|
+
absPath: f.absPath,
|
|
124
|
+
size: f.size,
|
|
125
|
+
mtimeMs: f.mtimeMs,
|
|
126
|
+
mimeType: guessMime(f.relPath),
|
|
127
|
+
content: null,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return { uploads, skipped: skips, ignored, gitDirs, findings };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Reporte agrupado de salteos. Un repo con un `venv/` adentro generaba miles de
|
|
136
|
+
* líneas "saltado …" que tapaban todo lo demás; acá se resume por motivo, con
|
|
137
|
+
* unos pocos ejemplos, y se sugiere qué ignorar.
|
|
138
|
+
*/
|
|
139
|
+
function reportSkipped(skips, { verbose }) {
|
|
140
|
+
if (skips.length === 0) return;
|
|
141
|
+
|
|
142
|
+
if (verbose) {
|
|
143
|
+
for (const s of skips) ui.warn(`saltado ${ui.c.dim(s.relPath)} — ${s.reason}`);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const byCode = new Map();
|
|
148
|
+
for (const s of skips) {
|
|
149
|
+
const code = s.code || 'otros';
|
|
150
|
+
if (!byCode.has(code)) byCode.set(code, []);
|
|
151
|
+
byCode.get(code).push(s);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
ui.warn(`${skips.length} entrada(s) salteada(s):`);
|
|
155
|
+
for (const [code, list] of byCode) {
|
|
156
|
+
ui.info(` ${ui.c.dim('·')} ${list.length} ${SKIP_LABELS[code] || code}`);
|
|
157
|
+
for (const s of list.slice(0, SKIP_EXAMPLES)) {
|
|
158
|
+
ui.info(` ${ui.c.dim(s.relPath)} — ${s.reason}`);
|
|
159
|
+
}
|
|
160
|
+
if (list.length > SKIP_EXAMPLES) {
|
|
161
|
+
ui.info(ui.c.dim(` … y ${list.length - SKIP_EXAMPLES} más`));
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
suggestIgnore(skips);
|
|
166
|
+
ui.info(ui.c.dim('(--verbose muestra la lista completa)'));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Si los salteos se concentran en una carpeta de primer nivel, decirlo. */
|
|
170
|
+
function suggestIgnore(skips) {
|
|
171
|
+
const bySegment = new Map();
|
|
172
|
+
for (const s of skips) {
|
|
173
|
+
const seg = s.relPath.split('/')[0];
|
|
174
|
+
if (!seg || seg === s.relPath) continue; // sueltos en la raíz: no hay carpeta que ignorar
|
|
175
|
+
bySegment.set(seg, (bySegment.get(seg) || 0) + 1);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const worst = [...bySegment.entries()]
|
|
179
|
+
.filter(([, n]) => n >= SUGGEST_MIN)
|
|
180
|
+
.sort((a, b) => b[1] - a[1])
|
|
181
|
+
.slice(0, 3);
|
|
182
|
+
if (worst.length === 0) return;
|
|
183
|
+
|
|
184
|
+
ui.info('');
|
|
185
|
+
ui.info('Si esas carpetas no van al repo, agregalas a .shiloignore:');
|
|
186
|
+
for (const [seg, n] of worst) {
|
|
187
|
+
ui.info(` ${ui.c.bold(`${seg}/`)} ${ui.c.dim(`(${n} salteados)`)}`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Muestra los hallazgos de secretos. Nunca imprime el valor detectado. */
|
|
192
|
+
function reportSecrets(findings) {
|
|
193
|
+
const groups = secrets.groupByFile(findings);
|
|
194
|
+
ui.error(`Posibles secretos en ${groups.length} archivo(s):`);
|
|
195
|
+
for (const g of groups) {
|
|
196
|
+
if (g.name) {
|
|
197
|
+
ui.info(` ${ui.c.red('!')} ${g.relPath} ${ui.c.dim(`(coincide con "${g.name}")`)}`);
|
|
198
|
+
}
|
|
199
|
+
for (const hit of g.content) {
|
|
200
|
+
ui.info(` ${ui.c.red('!')} ${g.relPath}:${hit.line} ${ui.c.dim(`— parece ${hit.label}`)}`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
ui.info('');
|
|
204
|
+
ui.info('No se subió nada. Opciones:');
|
|
205
|
+
ui.info(' · agregar esos archivos a .shiloignore (lo más común)');
|
|
206
|
+
ui.info(' · sacar el secreto del contenido y volver a intentar');
|
|
207
|
+
ui.info(' · si es un falso positivo, repetir con --allow-secrets');
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Todas las carpetas ancestro de un conjunto de rutas de archivo. */
|
|
211
|
+
function neededFolders(relPaths) {
|
|
212
|
+
const set = new Set();
|
|
213
|
+
for (const p of relPaths) {
|
|
214
|
+
const parts = p.split('/');
|
|
215
|
+
parts.pop(); // el nombre del archivo
|
|
216
|
+
let acc = '';
|
|
217
|
+
for (const part of parts) {
|
|
218
|
+
acc = acc ? `${acc}/${part}` : part;
|
|
219
|
+
set.add(acc);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return set;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* shilo push [carpeta] [--project <uuid>] [-m <msg>] [--dry-run] [--yes]
|
|
227
|
+
* [--allow-secrets] [--concurrency <n>] [--no-index] [--verbose]
|
|
35
228
|
*
|
|
36
229
|
* Replica una carpeta local a un repo de Shilo: crea archivos nuevos, actualiza
|
|
37
230
|
* los que cambiaron, salta binarios/oversize/nombres inválidos, y registra todo
|
|
@@ -44,6 +237,8 @@ async function push(args) {
|
|
|
44
237
|
return 1;
|
|
45
238
|
}
|
|
46
239
|
|
|
240
|
+
const verbose = !!args.verbose;
|
|
241
|
+
|
|
47
242
|
// 1) Resolver remote: --project pisa a .shilo.json.
|
|
48
243
|
const savedRemote = remote.read(dir);
|
|
49
244
|
const projectId =
|
|
@@ -77,108 +272,214 @@ async function push(args) {
|
|
|
77
272
|
return 1;
|
|
78
273
|
}
|
|
79
274
|
|
|
80
|
-
// 3)
|
|
81
|
-
const {
|
|
275
|
+
// 3) PREFLIGHT: todo offline. Recién después de esto se toca la red.
|
|
276
|
+
const { uploads, skipped, ignored, gitDirs, findings } = preflight(dir);
|
|
82
277
|
|
|
83
|
-
if (
|
|
84
|
-
|
|
85
|
-
|
|
278
|
+
if (gitDirs.length > 0) {
|
|
279
|
+
ui.warn(
|
|
280
|
+
`${gitDirs.length} repositorio(s) git podado(s) (traen todo el historial, ` +
|
|
281
|
+
`incluidos secretos ya borrados del árbol de trabajo):`
|
|
282
|
+
);
|
|
283
|
+
for (const g of gitDirs.slice(0, SKIP_EXAMPLES)) ui.info(` ${ui.c.dim(g)}`);
|
|
284
|
+
if (gitDirs.length > SKIP_EXAMPLES) {
|
|
285
|
+
ui.info(ui.c.dim(` … y ${gitDirs.length - SKIP_EXAMPLES} más`));
|
|
86
286
|
}
|
|
87
287
|
}
|
|
88
288
|
|
|
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
|
-
}
|
|
289
|
+
reportSkipped(skipped, { verbose });
|
|
112
290
|
|
|
113
|
-
|
|
114
|
-
ui.
|
|
291
|
+
if (findings.length > 0 && !args['allow-secrets']) {
|
|
292
|
+
ui.info('');
|
|
293
|
+
reportSecrets(findings);
|
|
294
|
+
return 1;
|
|
295
|
+
}
|
|
296
|
+
if (findings.length > 0) {
|
|
297
|
+
ui.warn(
|
|
298
|
+
`${secrets.groupByFile(findings).length} archivo(s) con posibles secretos ` +
|
|
299
|
+
`se van a subir igual (--allow-secrets).`
|
|
300
|
+
);
|
|
115
301
|
}
|
|
116
302
|
|
|
117
303
|
if (uploads.length === 0) {
|
|
118
304
|
ui.info('No hay archivos de texto para subir.');
|
|
119
|
-
ui.info(
|
|
120
|
-
ui.c.dim(
|
|
121
|
-
`(${ignored} ignorados, ${skipped.length + skippedBinary.length} saltados)`
|
|
122
|
-
)
|
|
123
|
-
);
|
|
305
|
+
ui.info(ui.c.dim(`(${ignored} ignorados, ${skipped.length} salteados)`));
|
|
124
306
|
return 0;
|
|
125
307
|
}
|
|
126
308
|
|
|
309
|
+
// 4) Cliente + control de ritmo. `onThrottle` baja la concurrencia sola si el
|
|
310
|
+
// servidor empieza a responder 429, en vez de insistir hasta que corte.
|
|
311
|
+
const limit = sync.resolveConcurrency(args);
|
|
312
|
+
const throttle = sync.makeThrottle(limit);
|
|
127
313
|
const client = new ShiloClient({
|
|
128
314
|
baseURL,
|
|
129
315
|
supabaseUrl: cfg.supabaseUrl || null,
|
|
130
316
|
supabaseAnonKey: cfg.supabaseAnonKey || null,
|
|
317
|
+
onThrottle: () => {
|
|
318
|
+
if (throttle.reduce()) {
|
|
319
|
+
ui.warn('El servidor está limitando el ritmo (429): bajando la concurrencia.');
|
|
320
|
+
}
|
|
321
|
+
},
|
|
131
322
|
});
|
|
132
323
|
|
|
133
324
|
ui.info(
|
|
134
325
|
`Proyecto ${ui.c.bold(projectId)} en ${ui.c.cyan(baseURL)} — ` +
|
|
135
326
|
`${uploads.length} archivo(s) de texto, ${ignored} ignorado(s), ` +
|
|
136
|
-
`${skipped.length
|
|
327
|
+
`${skipped.length} salteado(s).`
|
|
137
328
|
);
|
|
138
329
|
|
|
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.
|
|
330
|
+
// 5) Planificación. `mgr` serializa el refresh del JWT para que las llamadas
|
|
331
|
+
// paralelas no quemen el mismo refresh_token.
|
|
146
332
|
const mgr = sync.makeCredsManager(client, cfg);
|
|
147
333
|
const plan = { added: [], modified: [], unchanged: [], failed: [] };
|
|
148
334
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
335
|
+
// 5a) Árbol remoto completo en un solo recorrido (un request por CARPETA).
|
|
336
|
+
// Antes esto era un getFileMeta por ARCHIVO: para 142 archivos en 30
|
|
337
|
+
// carpetas, 142 requests pasan a 30.
|
|
338
|
+
let tree;
|
|
339
|
+
try {
|
|
340
|
+
tree = await sync.walkRemote(client, projectId, mgr, { concurrency: throttle });
|
|
341
|
+
} catch (e) {
|
|
342
|
+
ui.error(`No se pudo leer el estado del proyecto: ${e.message}`);
|
|
343
|
+
return 1;
|
|
344
|
+
}
|
|
345
|
+
config.persistCredsIfChanged(cfg, mgr.current);
|
|
346
|
+
|
|
347
|
+
const treeComplete = tree.errors.length === 0;
|
|
348
|
+
if (!treeComplete) {
|
|
349
|
+
ui.warn(
|
|
350
|
+
`No se pudieron listar ${tree.errors.length} carpeta(s) del proyecto: ` +
|
|
351
|
+
`la planificación puede quedar incompleta.`
|
|
352
|
+
);
|
|
353
|
+
if (verbose) {
|
|
354
|
+
for (const e of tree.errors) ui.info(` ${ui.c.dim(e.folderPath || '/')} — ${e.reason}`);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const remoteByPath = new Map(tree.files.map((f) => [f.repoPath, f]));
|
|
359
|
+
|
|
360
|
+
// 5b) Índice local: un archivo cuyo size+mtime son los del último push exitoso
|
|
361
|
+
// no cambió, así que si además el remoto tiene ese mismo tamaño, no hace
|
|
362
|
+
// falta bajar contenido para compararlo. Es lo que hace que un push sin
|
|
363
|
+
// cambios sea casi gratis.
|
|
364
|
+
const useIndex = !args['no-index'];
|
|
365
|
+
const index = useIndex
|
|
366
|
+
? pushindex.read(dir, { projectId, baseURL })
|
|
367
|
+
: { version: pushindex.INDEX_VERSION, projectId, baseURL, entries: {} };
|
|
368
|
+
|
|
369
|
+
const toCompare = []; // mismo tamaño, hay que mirar el contenido
|
|
370
|
+
const toResolve = []; // distinto tamaño: sólo falta su storage_path
|
|
371
|
+
|
|
372
|
+
for (const u of uploads) {
|
|
373
|
+
if (tree.folders.has(u.relPath)) {
|
|
374
|
+
plan.failed.push({ ...u, reason: 'ya existe una carpeta con ese nombre' });
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
152
377
|
|
|
153
|
-
|
|
154
|
-
if (
|
|
155
|
-
|
|
378
|
+
const r = remoteByPath.get(u.relPath);
|
|
379
|
+
if (!r) {
|
|
380
|
+
plan.added.push(u);
|
|
381
|
+
continue;
|
|
156
382
|
}
|
|
157
383
|
|
|
158
|
-
//
|
|
159
|
-
|
|
160
|
-
if (
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
if (
|
|
164
|
-
|
|
384
|
+
// Servidor viejo: el listado no trae storage_path, y el commit lo exige para
|
|
385
|
+
// las modificaciones. Se resuelve con un getFileMeta, como antes.
|
|
386
|
+
if (!r.storage_path) {
|
|
387
|
+
if (typeof r.size === 'number' && r.size !== u.size) {
|
|
388
|
+
toResolve.push({ ...u, remote: r });
|
|
389
|
+
} else if (useIndex && pushindex.isFresh(index, u).fresh) {
|
|
390
|
+
plan.unchanged.push(u);
|
|
391
|
+
} else {
|
|
392
|
+
toCompare.push({ ...u, remote: r });
|
|
165
393
|
}
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Camino rápido: el remoto publica el SHA-256 de su contenido, así que
|
|
398
|
+
// "¿cambió?" se responde con aritmética local. Cero requests, cero bytes
|
|
399
|
+
// bajados — antes esto era descargar el archivo entero para comparar.
|
|
400
|
+
if (r.content_hash) {
|
|
401
|
+
// Mejor aún: si el índice dice que el archivo local está intacto y su hash
|
|
402
|
+
// ya coincide con el del servidor, ni siquiera hace falta leerlo del disco.
|
|
403
|
+
if (useIndex) {
|
|
404
|
+
const { fresh, entry } = pushindex.isFresh(index, u);
|
|
405
|
+
if (fresh && entry && entry.hash === r.content_hash) {
|
|
406
|
+
plan.unchanged.push({ ...u, remote: r });
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
let content;
|
|
412
|
+
try {
|
|
413
|
+
content = await loadContent(u);
|
|
414
|
+
} catch (e) {
|
|
415
|
+
plan.failed.push({ ...u, reason: e.message });
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (pushindex.hashContent(content) === r.content_hash) {
|
|
420
|
+
plan.unchanged.push({ ...u, content, remote: r });
|
|
421
|
+
} else {
|
|
422
|
+
plan.modified.push({ ...u, content, remote: r });
|
|
423
|
+
}
|
|
424
|
+
continue;
|
|
166
425
|
}
|
|
167
|
-
return { kind: 'modified', u: { ...u, remote: meta.file } };
|
|
168
|
-
});
|
|
169
426
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
427
|
+
// Archivo anterior a la migración 010: no tiene hash. Camino viejo, pero ya
|
|
428
|
+
// sin el request extra para el storage_path.
|
|
429
|
+
if (typeof r.size === 'number' && r.size !== u.size) {
|
|
430
|
+
plan.modified.push({ ...u, remote: r });
|
|
173
431
|
continue;
|
|
174
432
|
}
|
|
175
|
-
|
|
176
|
-
|
|
433
|
+
|
|
434
|
+
if (useIndex && pushindex.isFresh(index, u).fresh) {
|
|
435
|
+
plan.unchanged.push(u);
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
toCompare.push({ ...u, remote: r });
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// 5c) Los que quedaron: un request cada uno, pero sólo por los candidatos a
|
|
442
|
+
// cambio, no por todo el repo.
|
|
443
|
+
const pending = toCompare.concat(toResolve);
|
|
444
|
+
if (pending.length > 0) {
|
|
445
|
+
const prog = ui.progress(pending.length, 'comparando');
|
|
446
|
+
const results = await sync.runPool(
|
|
447
|
+
pending,
|
|
448
|
+
throttle,
|
|
449
|
+
async (u) => {
|
|
450
|
+
// Distinto tamaño: ya sabemos que cambió, sólo hace falta la metadata
|
|
451
|
+
// (el commit exige el storage_path anterior).
|
|
452
|
+
if (typeof u.remote.size === 'number' && u.remote.size !== u.size) {
|
|
453
|
+
const creds = await mgr.valid();
|
|
454
|
+
const meta = await client.getFileMeta(projectId, u.relPath, creds);
|
|
455
|
+
if (!meta.exists) return { kind: 'added', u };
|
|
456
|
+
return { kind: 'modified', u: { ...u, remote: meta.file } };
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const content = await loadContent(u);
|
|
460
|
+
const creds = await mgr.valid();
|
|
461
|
+
const read = await client.readFile(projectId, u.relPath, creds);
|
|
462
|
+
if (read.file && read.file.content === content) {
|
|
463
|
+
return { kind: 'unchanged', u: { ...u, content, remote: read.file } };
|
|
464
|
+
}
|
|
465
|
+
return { kind: 'modified', u: { ...u, content, remote: read.file } };
|
|
466
|
+
},
|
|
467
|
+
{ onProgress: (done) => prog.update(done) }
|
|
468
|
+
);
|
|
469
|
+
prog.clear();
|
|
470
|
+
|
|
471
|
+
for (const r of results) {
|
|
472
|
+
if (r && r.__error) {
|
|
473
|
+
plan.failed.push({ ...r.item, reason: r.__error });
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
plan[r.kind].push(r.u);
|
|
477
|
+
}
|
|
177
478
|
}
|
|
178
479
|
|
|
179
480
|
config.persistCredsIfChanged(cfg, mgr.current);
|
|
180
481
|
|
|
181
|
-
//
|
|
482
|
+
// 5d) Atribución: para los archivos que vamos a pisar, quién los tocó último.
|
|
182
483
|
// Es lo que distingue "esto lo subí yo antes" de "esto lo va a perder un
|
|
183
484
|
// compañero". Best-effort: si el historial no se puede leer, seguimos.
|
|
184
485
|
let attribution = { index: new Map() };
|
|
@@ -219,6 +520,7 @@ async function push(args) {
|
|
|
219
520
|
for (const f of plan.failed) ui.error(`${f.relPath} — ${f.reason}`);
|
|
220
521
|
return 1;
|
|
221
522
|
}
|
|
523
|
+
if (useIndex && !dryRun) saveIndex(dir, index, plan, uploads);
|
|
222
524
|
ui.success('Todo al día. Nada para subir.');
|
|
223
525
|
return 0;
|
|
224
526
|
}
|
|
@@ -265,142 +567,387 @@ async function push(args) {
|
|
|
265
567
|
}
|
|
266
568
|
}
|
|
267
569
|
|
|
268
|
-
// 8) Ejecutar subidas.
|
|
269
|
-
|
|
270
|
-
|
|
570
|
+
// 8) Ejecutar subidas. Cada lote se commitea apenas termina (ver paso 9): si
|
|
571
|
+
// el push se corta a la mitad, lo ya escrito queda en el historial en vez
|
|
572
|
+
// de quedar en storage sin ningún commit que lo referencie.
|
|
271
573
|
const failedWrites = [];
|
|
574
|
+
const pushedAt = Date.now();
|
|
575
|
+
let committed = 0;
|
|
576
|
+
let commitCount = 0;
|
|
577
|
+
|
|
578
|
+
/** Log por archivo: en TTY lo tapa la barra de progreso, así que va sólo con --verbose. */
|
|
579
|
+
const logFile = (prog, msg) => {
|
|
580
|
+
if (verbose) {
|
|
581
|
+
prog.clear();
|
|
582
|
+
ui.success(msg);
|
|
583
|
+
} else {
|
|
584
|
+
prog.line(`✓ ${msg}`);
|
|
585
|
+
}
|
|
586
|
+
};
|
|
587
|
+
const logError = (prog, msg) => {
|
|
588
|
+
prog.clear();
|
|
589
|
+
ui.error(msg);
|
|
590
|
+
};
|
|
272
591
|
|
|
273
|
-
// 8a)
|
|
274
|
-
//
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
//
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
592
|
+
// 8a) Carpetas faltantes, por nivel. El árbol remoto ya nos dice cuáles
|
|
593
|
+
// existen, así que no hace falta listar antes de crear cada una; y crear
|
|
594
|
+
// nivel por nivel (paralelo dentro del nivel, serie entre niveles)
|
|
595
|
+
// garantiza que el padre exista sin depender del 409 del backend, que no
|
|
596
|
+
// es confiable ante dos creaciones simultáneas de la misma carpeta.
|
|
597
|
+
//
|
|
598
|
+
// Se ejecuta a demanda: el endpoint de lote hace mkdir -p por su cuenta,
|
|
599
|
+
// así que estos requests sólo hacen falta en el camino archivo-por-archivo.
|
|
600
|
+
const failedFolders = [];
|
|
601
|
+
let foldersEnsured = false;
|
|
602
|
+
|
|
603
|
+
async function ensureFolders() {
|
|
604
|
+
if (foldersEnsured) return;
|
|
605
|
+
foldersEnsured = true;
|
|
606
|
+
if (plan.added.length === 0) return;
|
|
607
|
+
|
|
608
|
+
if (treeComplete) {
|
|
609
|
+
const missing = [...neededFolders(plan.added.map((u) => u.relPath))].filter(
|
|
610
|
+
(f) => !tree.folders.has(f)
|
|
611
|
+
);
|
|
612
|
+
const byDepth = new Map();
|
|
613
|
+
for (const f of missing) {
|
|
614
|
+
const d = f.split('/').length;
|
|
615
|
+
if (!byDepth.has(d)) byDepth.set(d, []);
|
|
616
|
+
byDepth.get(d).push(f);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
for (const depth of [...byDepth.keys()].sort((a, b) => a - b)) {
|
|
620
|
+
const level = byDepth.get(depth);
|
|
621
|
+
const results = await sync.runPool(level, throttle, async (folderPath) => {
|
|
622
|
+
const parts = folderPath.split('/');
|
|
623
|
+
const name = parts.pop();
|
|
624
|
+
const creds = await mgr.valid();
|
|
625
|
+
await client.createFolder(projectId, name, parts.join('/'), creds);
|
|
626
|
+
return folderPath;
|
|
627
|
+
});
|
|
628
|
+
for (const r of results) {
|
|
629
|
+
if (r && r.__error) {
|
|
630
|
+
failedFolders.push({ folderPath: r.item, reason: r.__error });
|
|
631
|
+
ui.error(`carpeta ${r.item} — ${r.__error}`);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
} else {
|
|
636
|
+
// Árbol incompleto: no sabemos qué existe. Volvemos al camino conservador.
|
|
637
|
+
const ensuredCache = new Set();
|
|
638
|
+
for (const u of plan.added) {
|
|
639
|
+
try {
|
|
640
|
+
const creds = await mgr.valid();
|
|
641
|
+
await client.ensurePath(projectId, u.relPath, creds, ensuredCache);
|
|
642
|
+
} catch (e) {
|
|
643
|
+
failedFolders.push({ folderPath: path.posix.dirname(u.relPath), reason: e.message });
|
|
644
|
+
ui.error(`${u.relPath} — ${e.message}`);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
288
647
|
}
|
|
648
|
+
config.persistCredsIfChanged(cfg, mgr.current);
|
|
289
649
|
}
|
|
290
|
-
config.persistCredsIfChanged(cfg, mgr.current);
|
|
291
650
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
651
|
+
const blocked = (relPath) =>
|
|
652
|
+
failedFolders.some((f) => f.folderPath && relPath.startsWith(`${f.folderPath}/`));
|
|
653
|
+
|
|
654
|
+
// 8b) Subidas. El costo dominante no es el trabajo del servidor sino la
|
|
655
|
+
// latencia de cada round-trip, así que van agrupadas: el endpoint de lote
|
|
656
|
+
// escribe hasta 100 archivos por request y crea las carpetas que falten.
|
|
657
|
+
// El contenido se lee lote por lote, no todo de una: un repo grande no
|
|
658
|
+
// tiene por qué entrar entero en memoria.
|
|
659
|
+
const BATCH_MAX_FILES = COMMIT_BATCH; // 100: mismo techo que el commit
|
|
660
|
+
const BATCH_MAX_BYTES = 4 * 1024 * 1024; // el server corta en 5 MB; margen
|
|
661
|
+
|
|
662
|
+
const toUpload = [
|
|
663
|
+
...plan.added.map((u) => ({ u, isNew: true })),
|
|
664
|
+
...plan.modified.map((u) => ({ u, isNew: false })),
|
|
665
|
+
];
|
|
666
|
+
|
|
667
|
+
// Empaquetado por cantidad Y por tamaño. Un archivo más grande que el
|
|
668
|
+
// presupuesto termina solo en su lote, y ahí se sube por el endpoint
|
|
669
|
+
// individual, que admite hasta 25 MB.
|
|
670
|
+
const batches = [];
|
|
671
|
+
{
|
|
672
|
+
let current = [];
|
|
673
|
+
let currentBytes = 0;
|
|
674
|
+
for (const e of toUpload) {
|
|
675
|
+
const bytes = typeof e.u.size === 'number' ? e.u.size : 0;
|
|
676
|
+
const full =
|
|
677
|
+
current.length >= BATCH_MAX_FILES || currentBytes + bytes > BATCH_MAX_BYTES;
|
|
678
|
+
if (current.length > 0 && full) {
|
|
679
|
+
batches.push(current);
|
|
680
|
+
current = [];
|
|
681
|
+
currentBytes = 0;
|
|
682
|
+
}
|
|
683
|
+
current.push(e);
|
|
684
|
+
currentBytes += bytes;
|
|
685
|
+
}
|
|
686
|
+
if (current.length > 0) batches.push(current);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
const upProg = ui.progress(toUpload.length, 'subiendo');
|
|
690
|
+
let upDone = 0;
|
|
691
|
+
const tickUp = (n = 1) => {
|
|
692
|
+
upDone += n;
|
|
693
|
+
upProg.update(upDone);
|
|
694
|
+
};
|
|
695
|
+
|
|
696
|
+
const uploaded = []; // { relPath, size, mtimeMs, hash } para el índice
|
|
697
|
+
let batchSupported = true; // se descubre en el primer lote
|
|
698
|
+
|
|
699
|
+
/** Escribe un lote entero en un request. Devuelve un resultado por archivo. */
|
|
700
|
+
async function writeViaBatch(entries) {
|
|
701
|
+
const payload = entries.map((e) => ({
|
|
702
|
+
path: e.u.relPath,
|
|
703
|
+
content: e.content,
|
|
704
|
+
mimeType: e.u.mimeType,
|
|
705
|
+
}));
|
|
297
706
|
const creds = await mgr.valid();
|
|
298
|
-
const
|
|
707
|
+
const { results } = await client.writeBatch(projectId, payload, creds);
|
|
299
708
|
|
|
300
|
-
|
|
709
|
+
return entries.map((e, i) => {
|
|
710
|
+
const r = results[i];
|
|
711
|
+
if (!r || !r.ok) {
|
|
712
|
+
return { ok: false, reason: (r && r.error) || 'el servidor no devolvió resultado' };
|
|
713
|
+
}
|
|
714
|
+
const f = r.file || {};
|
|
715
|
+
const action = r.action === 'created' ? 'added' : 'modified';
|
|
716
|
+
const change = { file_id: f.id, action, new_storage_path: f.storage_path };
|
|
717
|
+
if (action === 'modified') {
|
|
718
|
+
// El backend devuelve el storage_path que acaba de reemplazar; sólo si
|
|
719
|
+
// faltara caemos a lo que sabíamos del árbol remoto.
|
|
720
|
+
change.previous_storage_path =
|
|
721
|
+
f.previous_storage_path || (e.u.remote && e.u.remote.storage_path) || f.storage_path;
|
|
722
|
+
}
|
|
723
|
+
return { ok: true, change, symbol: action === 'added' ? '+' : '~' };
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/** Camino archivo-por-archivo: server sin /batch, o archivo demasiado grande. */
|
|
728
|
+
async function writeViaSingle(entries) {
|
|
729
|
+
await ensureFolders();
|
|
730
|
+
|
|
731
|
+
const outcomes = new Array(entries.length);
|
|
732
|
+
const queue = [];
|
|
733
|
+
entries.forEach((e, i) => {
|
|
734
|
+
if (e.isNew && blocked(e.u.relPath)) {
|
|
735
|
+
outcomes[i] = { ok: false, reason: 'no se pudo crear su carpeta' };
|
|
736
|
+
} else {
|
|
737
|
+
queue.push({ e, i });
|
|
738
|
+
}
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
const results = await sync.runPool(queue, throttle, async ({ e }) => {
|
|
742
|
+
const u = e.u;
|
|
743
|
+
if (e.isNew) {
|
|
744
|
+
const creds = await mgr.valid();
|
|
745
|
+
const created = await client.createFile(projectId, u.relPath, e.content, u.mimeType, creds);
|
|
746
|
+
if (created.created && created.file) {
|
|
747
|
+
return {
|
|
748
|
+
ok: true,
|
|
749
|
+
symbol: '+',
|
|
750
|
+
change: {
|
|
751
|
+
file_id: created.file.id,
|
|
752
|
+
action: 'added',
|
|
753
|
+
new_storage_path: created.file.storage_path,
|
|
754
|
+
},
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
// 409: apareció entremedio; caemos a update.
|
|
758
|
+
const writeCreds = await mgr.valid();
|
|
759
|
+
const upd = await client.writeFile(projectId, u.relPath, e.content, u.mimeType, writeCreds);
|
|
760
|
+
return {
|
|
761
|
+
ok: true,
|
|
762
|
+
symbol: '~',
|
|
763
|
+
change: {
|
|
764
|
+
file_id: upd.file.id,
|
|
765
|
+
action: 'modified',
|
|
766
|
+
previous_storage_path:
|
|
767
|
+
(created.file && created.file.storage_path) || upd.file.storage_path,
|
|
768
|
+
new_storage_path: upd.file.storage_path,
|
|
769
|
+
},
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
const creds = await mgr.valid();
|
|
774
|
+
const upd = await client.writeFile(projectId, u.relPath, e.content, u.mimeType, creds);
|
|
301
775
|
return {
|
|
302
|
-
|
|
303
|
-
symbol: '
|
|
776
|
+
ok: true,
|
|
777
|
+
symbol: '~',
|
|
304
778
|
change: {
|
|
305
|
-
file_id:
|
|
306
|
-
action: '
|
|
307
|
-
|
|
779
|
+
file_id: upd.file.id,
|
|
780
|
+
action: 'modified',
|
|
781
|
+
previous_storage_path: u.remote ? u.remote.storage_path : upd.file.storage_path,
|
|
782
|
+
new_storage_path: upd.file.storage_path,
|
|
308
783
|
},
|
|
309
784
|
};
|
|
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
|
-
});
|
|
785
|
+
});
|
|
325
786
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
787
|
+
// `runPool` devuelve los resultados alineados con la entrada, así que el
|
|
788
|
+
// índice de `queue` alcanza para volver a la posición original.
|
|
789
|
+
for (let k = 0; k < results.length; k += 1) {
|
|
790
|
+
const r = results[k];
|
|
791
|
+
outcomes[queue[k].i] = r && r.__error ? { ok: false, reason: r.__error } : r;
|
|
331
792
|
}
|
|
332
|
-
|
|
333
|
-
ui.success(`${ui.c[r.symbol === '+' ? 'green' : 'yellow'](r.symbol)} ${r.u.relPath}`);
|
|
793
|
+
return outcomes;
|
|
334
794
|
}
|
|
335
|
-
config.persistCredsIfChanged(cfg, mgr.current);
|
|
336
795
|
|
|
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
|
-
});
|
|
796
|
+
for (let bi = 0; bi < batches.length; bi += 1) {
|
|
797
|
+
const entries = batches[bi];
|
|
350
798
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
799
|
+
// Un fallo de lectura (dejó de ser texto, permisos) saca a ese archivo del
|
|
800
|
+
// lote pero no arrastra a los demás.
|
|
801
|
+
const loaded = [];
|
|
802
|
+
for (const e of entries) {
|
|
803
|
+
try {
|
|
804
|
+
e.content = await loadContent(e.u);
|
|
805
|
+
loaded.push(e);
|
|
806
|
+
} catch (err) {
|
|
807
|
+
failedWrites.push({ relPath: e.u.relPath, reason: err.message });
|
|
808
|
+
logError(upProg, `${e.u.relPath} — ${err.message}`);
|
|
809
|
+
tickUp();
|
|
810
|
+
}
|
|
356
811
|
}
|
|
357
|
-
|
|
358
|
-
ui.success(`${ui.c.yellow('~')} ${r.u.relPath}`);
|
|
359
|
-
}
|
|
360
|
-
config.persistCredsIfChanged(cfg, mgr.current);
|
|
812
|
+
if (loaded.length === 0) continue;
|
|
361
813
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
814
|
+
const oversize =
|
|
815
|
+
loaded.length === 1 && Buffer.byteLength(loaded[0].content, 'utf8') > BATCH_MAX_BYTES;
|
|
816
|
+
let outcomes = null;
|
|
817
|
+
|
|
818
|
+
if (batchSupported && !oversize) {
|
|
819
|
+
try {
|
|
820
|
+
outcomes = await writeViaBatch(loaded);
|
|
821
|
+
} catch (err) {
|
|
822
|
+
if (err.notSupported) {
|
|
823
|
+
batchSupported = false;
|
|
824
|
+
upProg.clear();
|
|
825
|
+
ui.warn('El servidor no acepta escritura por lotes; se sube archivo por archivo.');
|
|
826
|
+
} else {
|
|
827
|
+
// Falla del request entero (red, 5xx, permisos): ningún archivo del
|
|
828
|
+
// lote se escribió.
|
|
829
|
+
for (const e of loaded) {
|
|
830
|
+
failedWrites.push({ relPath: e.u.relPath, reason: err.message });
|
|
831
|
+
logError(upProg, `${e.u.relPath} — ${err.message}`);
|
|
832
|
+
}
|
|
833
|
+
tickUp(loaded.length);
|
|
834
|
+
continue;
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
if (!outcomes) outcomes = await writeViaSingle(loaded);
|
|
840
|
+
config.persistCredsIfChanged(cfg, mgr.current);
|
|
841
|
+
|
|
842
|
+
const batchChanges = [];
|
|
843
|
+
for (let i = 0; i < loaded.length; i += 1) {
|
|
844
|
+
const e = loaded[i];
|
|
845
|
+
const o = outcomes[i];
|
|
846
|
+
if (!o || !o.ok) {
|
|
847
|
+
const reason = (o && o.reason) || 'error desconocido';
|
|
848
|
+
failedWrites.push({ relPath: e.u.relPath, reason });
|
|
849
|
+
logError(upProg, `${e.u.relPath} — ${reason}`);
|
|
850
|
+
continue;
|
|
851
|
+
}
|
|
852
|
+
batchChanges.push(o.change);
|
|
853
|
+
uploaded.push({ ...e.u, hash: pushindex.hashContent(e.content) });
|
|
854
|
+
logFile(upProg, `${ui.c[o.symbol === '+' ? 'green' : 'yellow'](o.symbol)} ${e.u.relPath}`);
|
|
855
|
+
}
|
|
856
|
+
tickUp(loaded.length);
|
|
857
|
+
|
|
858
|
+
// 9) Commit del lote, apenas termina de escribirse. Juntar todos los cambios
|
|
859
|
+
// para un commit final ahorraba requests, pero dejaba una ventana larga
|
|
860
|
+
// en la que los archivos ya estaban escritos y ningún commit los
|
|
861
|
+
// mencionaba; si el push moría ahí, el historial quedaba mintiendo.
|
|
862
|
+
if (batchChanges.length > 0) {
|
|
863
|
+
const partNo = bi + 1;
|
|
864
|
+
const msg = buildMessage(
|
|
865
|
+
message,
|
|
866
|
+
batchChanges.length,
|
|
867
|
+
toUpload.length,
|
|
868
|
+
partNo,
|
|
869
|
+
batches.length,
|
|
870
|
+
card
|
|
871
|
+
);
|
|
371
872
|
try {
|
|
372
873
|
const creds = await mgr.valid();
|
|
373
|
-
await client.createCommit(projectId, msg,
|
|
374
|
-
committed +=
|
|
874
|
+
await client.createCommit(projectId, msg, batchChanges, creds);
|
|
875
|
+
committed += batchChanges.length;
|
|
876
|
+
commitCount += 1;
|
|
375
877
|
config.persistCredsIfChanged(cfg, mgr.current);
|
|
376
878
|
} catch (e) {
|
|
377
|
-
ui.error(`Fallo al crear el commit (lote ${partNo}/${
|
|
879
|
+
ui.error(`Fallo al crear el commit (lote ${partNo}/${batches.length}): ${e.message}`);
|
|
378
880
|
failedWrites.push({ relPath: `<commit lote ${partNo}>`, reason: e.message });
|
|
379
881
|
}
|
|
380
882
|
}
|
|
883
|
+
|
|
884
|
+
// El contenido ya se hasheó y se subió: soltarlo mantiene el pico de memoria
|
|
885
|
+
// atado al tamaño del lote y no al del repo.
|
|
886
|
+
for (const e of loaded) {
|
|
887
|
+
e.content = null;
|
|
888
|
+
e.u.content = null;
|
|
889
|
+
}
|
|
381
890
|
}
|
|
891
|
+
upProg.clear();
|
|
382
892
|
|
|
383
893
|
config.persistCredsIfChanged(cfg, mgr.current);
|
|
384
894
|
|
|
385
|
-
// 10)
|
|
386
|
-
//
|
|
387
|
-
if (
|
|
895
|
+
// 10) Índice local: lo que subimos bien y lo que ya estaba igual. El próximo
|
|
896
|
+
// push arranca sabiendo qué no hace falta ni leer.
|
|
897
|
+
if (useIndex) {
|
|
898
|
+
for (const u of uploaded) {
|
|
899
|
+
pushindex.record(index, u, pushedAt);
|
|
900
|
+
}
|
|
901
|
+
saveIndex(dir, index, plan, uploads, pushedAt);
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// 11) Anotar la sincronización: `status` la usa después para saber qué del
|
|
905
|
+
// remoto es posterior a lo último que subimos. Sólo si TODO salió bien:
|
|
906
|
+
// con archivos fallados, marcar "sincronizado" haría que status mienta.
|
|
907
|
+
if (failedWrites.length === 0 && plan.failed.length === 0) {
|
|
388
908
|
remote.touchSync(dir, { projectId, baseURL });
|
|
389
909
|
}
|
|
390
910
|
|
|
391
|
-
//
|
|
911
|
+
// 12) Resumen final.
|
|
392
912
|
ui.info('');
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
ui.success(
|
|
396
|
-
`Push completo: ${committed} archivo(s) en ${nCommits} commit(s).`
|
|
397
|
-
);
|
|
913
|
+
if (failedWrites.length === 0 && plan.failed.length === 0) {
|
|
914
|
+
ui.success(`Push completo: ${committed} archivo(s) en ${commitCount} commit(s).`);
|
|
398
915
|
return 0;
|
|
399
916
|
}
|
|
400
|
-
ui.
|
|
917
|
+
for (const f of plan.failed) ui.error(`${f.relPath} — ${f.reason}`);
|
|
918
|
+
ui.warn(
|
|
919
|
+
`Push con problemas: ${failedWrites.length + plan.failed.length} archivo(s)/commit(s) fallaron.`
|
|
920
|
+
);
|
|
401
921
|
return 1;
|
|
402
922
|
}
|
|
403
923
|
|
|
924
|
+
/**
|
|
925
|
+
* Lee el contenido completo de un archivo, cacheado en la propia entrada.
|
|
926
|
+
* Re-valida que siga siendo texto: entre el preflight y la subida pudo cambiar.
|
|
927
|
+
*/
|
|
928
|
+
async function loadContent(u) {
|
|
929
|
+
if (u.content != null) return u.content;
|
|
930
|
+
const buf = await fs.promises.readFile(u.absPath);
|
|
931
|
+
const cls = classifyContent(path.basename(u.relPath), buf);
|
|
932
|
+
if (!cls.isText) throw new Error(cls.reason);
|
|
933
|
+
u.content = cls.content;
|
|
934
|
+
return u.content;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
/**
|
|
938
|
+
* Registra en el índice los archivos que ya estaban idénticos (para no volver a
|
|
939
|
+
* compararlos), saca los que ya no existen y lo escribe.
|
|
940
|
+
*/
|
|
941
|
+
function saveIndex(dir, index, plan, uploads, pushedAt = Date.now()) {
|
|
942
|
+
for (const u of plan.unchanged) {
|
|
943
|
+
if (u.content != null) {
|
|
944
|
+
pushindex.record(index, { ...u, hash: pushindex.hashContent(u.content) }, pushedAt);
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
pushindex.prune(index, new Set(uploads.map((u) => u.relPath)));
|
|
948
|
+
pushindex.write(dir, index);
|
|
949
|
+
}
|
|
950
|
+
|
|
404
951
|
/**
|
|
405
952
|
* Arma el mensaje del commit. Si vino `--card`, se agrega como trailer
|
|
406
953
|
* `[card: <id>]` al final: queda visible en el historial y es fácil de grepear
|