shilo-cli 0.2.0 → 0.3.1
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 +93 -2
- package/bin/shilo.js +41 -2
- package/package.json +6 -2
- package/src/ShiloClient.js +22 -0
- package/src/commands/diff.js +158 -0
- package/src/commands/init.js +6 -1
- package/src/commands/log.js +155 -0
- package/src/commands/pull.js +239 -0
- package/src/commands/push.js +190 -75
- package/src/commands/status.js +187 -0
- package/src/context.js +82 -0
- package/src/remote.js +29 -3
- package/src/sync.js +344 -0
- package/src/textdiff.js +196 -0
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const config = require('../config');
|
|
6
|
+
const remote = require('../remote');
|
|
7
|
+
const prompt = require('../prompt');
|
|
8
|
+
const ui = require('../ui');
|
|
9
|
+
const sync = require('../sync');
|
|
10
|
+
const { validatePath } = require('../textguard');
|
|
11
|
+
const { resolveContext } = require('../context');
|
|
12
|
+
|
|
13
|
+
const CONCURRENCY = sync.CONCURRENCY;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Resuelve la ruta absoluta de escritura para un `repoPath`, blindando contra
|
|
17
|
+
* path traversal. Valida cada segmento con las mismas reglas del backend y
|
|
18
|
+
* exige que el destino final quede dentro de `destDir`.
|
|
19
|
+
* Devuelve `{ ok: true, target }` o `{ ok: false, reason }`.
|
|
20
|
+
*/
|
|
21
|
+
function safeTarget(destDir, repoPath) {
|
|
22
|
+
const check = validatePath(repoPath);
|
|
23
|
+
if (!check.ok) return { ok: false, reason: check.reason };
|
|
24
|
+
|
|
25
|
+
const target = path.resolve(destDir, repoPath);
|
|
26
|
+
const rel = path.relative(destDir, target);
|
|
27
|
+
if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
28
|
+
return { ok: false, reason: 'la ruta escaparía de la carpeta destino' };
|
|
29
|
+
}
|
|
30
|
+
return { ok: true, target };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* shilo pull [carpeta] [--project <uuid>] [--base-url <url>] [--dry-run] [--yes] [--force]
|
|
35
|
+
*
|
|
36
|
+
* Baja un proyecto de Shilo a una carpeta local y deja `.shilo.json` listo para
|
|
37
|
+
* un `push` posterior.
|
|
38
|
+
*
|
|
39
|
+
* Seguridad ante trabajo local sin guardar: el pull NO pisa un archivo local que
|
|
40
|
+
* difiera del remoto salvo con `--force`. Los archivos que sólo existen en el
|
|
41
|
+
* remoto, y los que ya coinciden byte a byte, se resuelven solos. Un archivo que
|
|
42
|
+
* sólo existe localmente nunca se toca (el pull no borra).
|
|
43
|
+
*/
|
|
44
|
+
async function pull(args) {
|
|
45
|
+
const ctx = resolveContext(args, { requireDir: false });
|
|
46
|
+
if (!ctx.ok) return ctx.code;
|
|
47
|
+
|
|
48
|
+
const { dir, projectId, baseURL, cfg, client, mgr } = ctx;
|
|
49
|
+
|
|
50
|
+
// La carpeta destino puede no existir todavía: la creamos al escribir. Si
|
|
51
|
+
// existe pero es un archivo, es un error del usuario.
|
|
52
|
+
if (fs.existsSync(dir) && !fs.statSync(dir).isDirectory()) {
|
|
53
|
+
ui.error(`No es una carpeta válida: ${dir}`);
|
|
54
|
+
return 1;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const dryRun = !!args['dry-run'];
|
|
58
|
+
|
|
59
|
+
ui.info(`Proyecto ${ui.c.bold(projectId)} en ${ui.c.cyan(baseURL)} — recorriendo árbol ...`);
|
|
60
|
+
|
|
61
|
+
let tree;
|
|
62
|
+
try {
|
|
63
|
+
tree = await sync.walkRemote(client, projectId, mgr);
|
|
64
|
+
} catch (e) {
|
|
65
|
+
ui.error(`No se pudo recorrer el proyecto: ${e.message}`);
|
|
66
|
+
return 1;
|
|
67
|
+
} finally {
|
|
68
|
+
config.persistCredsIfChanged(cfg, mgr.current);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
for (const err of tree.errors) {
|
|
72
|
+
ui.warn(`no se pudo listar ${ui.c.dim(err.folderPath || '(raíz)')} — ${err.reason}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 1) Separar texto (descargable) de lo que no se puede bajar sin corromper.
|
|
76
|
+
const candidates = [];
|
|
77
|
+
const skippedBinary = [];
|
|
78
|
+
const unsafe = [];
|
|
79
|
+
for (const f of tree.files) {
|
|
80
|
+
const safe = safeTarget(dir, f.repoPath);
|
|
81
|
+
if (!safe.ok) {
|
|
82
|
+
unsafe.push({ repoPath: f.repoPath, reason: safe.reason });
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (!sync.isTextMime(f.mime_type)) {
|
|
86
|
+
skippedBinary.push({ repoPath: f.repoPath, reason: `mime no textual (${f.mime_type})` });
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
candidates.push({ ...f, target: safe.target });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
for (const u of unsafe) ui.warn(`saltado ${ui.c.dim(u.repoPath)} — ${u.reason}`);
|
|
93
|
+
for (const s of skippedBinary) ui.warn(`saltado ${ui.c.dim(s.repoPath)} — ${s.reason}`);
|
|
94
|
+
|
|
95
|
+
// 2) Clasificar contra lo que hay en disco, sin bajar nada todavía.
|
|
96
|
+
// `maybeSame` (mismo tamaño) se confirma recién al descargar el contenido.
|
|
97
|
+
const local = sync.walkLocal(dir);
|
|
98
|
+
const localByPath = new Map(local.files.map((f) => [f.repoPath, f]));
|
|
99
|
+
|
|
100
|
+
const newFiles = []; // sólo en el remoto: bajar sin riesgo
|
|
101
|
+
const maybeSame = []; // existe local con el mismo tamaño
|
|
102
|
+
const conflicting = []; // existe local con distinto tamaño: pisaría trabajo
|
|
103
|
+
for (const f of candidates) {
|
|
104
|
+
const localFile = localByPath.get(f.repoPath);
|
|
105
|
+
if (!localFile) newFiles.push(f);
|
|
106
|
+
else if (typeof f.size === 'number' && f.size !== localFile.size) conflicting.push(f);
|
|
107
|
+
else maybeSame.push(f);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const remotePaths = new Set(candidates.map((f) => f.repoPath));
|
|
111
|
+
const onlyLocal = local.files.filter((f) => !remotePaths.has(f.repoPath));
|
|
112
|
+
|
|
113
|
+
ui.info('');
|
|
114
|
+
ui.info(
|
|
115
|
+
` ${ui.c.green('nuevos:')} ${newFiles.length}\n` +
|
|
116
|
+
` ${ui.c.yellow('en conflicto:')} ${conflicting.length} ${ui.c.dim('(difieren en tamaño del local)')}\n` +
|
|
117
|
+
` ${ui.c.dim('posiblemente iguales:')} ${maybeSame.length}\n` +
|
|
118
|
+
` ${ui.c.dim('sólo locales:')} ${onlyLocal.length} ${ui.c.dim('(el pull no los toca)')}` +
|
|
119
|
+
(skippedBinary.length ? `\n ${ui.c.dim('binarios saltados:')} ${skippedBinary.length}` : '') +
|
|
120
|
+
(unsafe.length ? `\n ${ui.c.red('inseguros:')} ${unsafe.length}` : '')
|
|
121
|
+
);
|
|
122
|
+
ui.info('');
|
|
123
|
+
|
|
124
|
+
// 3) --dry-run: mostrar el plan contra el estado local y salir sin tocar disco.
|
|
125
|
+
if (dryRun) {
|
|
126
|
+
for (const f of newFiles) ui.info(` ${ui.c.green('↓')} ${f.repoPath} ${ui.c.dim('(nuevo)')}`);
|
|
127
|
+
for (const f of conflicting) {
|
|
128
|
+
const localFile = localByPath.get(f.repoPath);
|
|
129
|
+
const delta = f.size - localFile.size;
|
|
130
|
+
ui.info(
|
|
131
|
+
` ${ui.c.yellow('~')} ${f.repoPath} ` +
|
|
132
|
+
ui.c.dim(`(sobrescribe tu versión local, ${delta > 0 ? '+' : ''}${delta} bytes)`)
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
for (const f of onlyLocal) ui.info(` ${ui.c.dim('·')} ${ui.c.dim(`${f.repoPath} (sólo local, se conserva)`)}`);
|
|
136
|
+
ui.info('');
|
|
137
|
+
if (conflicting.length > 0) {
|
|
138
|
+
ui.warn(`${conflicting.length} archivo(s) locales se sobrescribirían: hace falta --force.`);
|
|
139
|
+
ui.info(ui.c.dim('Mirá "shilo diff <archivo>" antes de bajarlos.'));
|
|
140
|
+
}
|
|
141
|
+
ui.info(
|
|
142
|
+
ui.c.dim(
|
|
143
|
+
'(--dry-run: no se bajó nada. Los "posiblemente iguales" se confirman al descargar.)'
|
|
144
|
+
)
|
|
145
|
+
);
|
|
146
|
+
return 0;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// 4) Sin --force, los conflictos quedan afuera en vez de abortar todo el pull:
|
|
150
|
+
// lo nuevo se baja igual y el usuario decide qué hacer con el resto.
|
|
151
|
+
let toDownload = [...newFiles, ...maybeSame];
|
|
152
|
+
if (conflicting.length > 0) {
|
|
153
|
+
if (args.force) {
|
|
154
|
+
toDownload = toDownload.concat(conflicting);
|
|
155
|
+
} else {
|
|
156
|
+
ui.warn(`${conflicting.length} archivo(s) difieren de tu copia local y NO se van a bajar:`);
|
|
157
|
+
for (const f of conflicting) ui.info(` ${ui.c.yellow('~')} ${f.repoPath}`);
|
|
158
|
+
ui.info(ui.c.dim('Usá --force para sobrescribirlos, o "shilo diff <archivo>" para comparar.'));
|
|
159
|
+
ui.info('');
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (toDownload.length === 0) {
|
|
164
|
+
ui.info('No hay archivos para bajar.');
|
|
165
|
+
return conflicting.length > 0 && !args.force ? 1 : 0;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// 5) Confirmar (salvo --yes).
|
|
169
|
+
if (!args.yes) {
|
|
170
|
+
const overwrites = toDownload.filter((f) => fs.existsSync(f.target)).length;
|
|
171
|
+
const label = overwrites
|
|
172
|
+
? `¿Bajar ${toDownload.length} archivo(s) a "${dir}" (${overwrites} ya existen)?`
|
|
173
|
+
: `¿Bajar ${toDownload.length} archivo(s) a "${dir}"?`;
|
|
174
|
+
const ok = await prompt.confirm(label, true);
|
|
175
|
+
if (!ok) {
|
|
176
|
+
ui.info('Cancelado.');
|
|
177
|
+
return 0;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// 6) Descargar y escribir, con pool. Recién acá se sabe si un archivo del
|
|
182
|
+
// mismo tamaño era realmente idéntico: si lo era, no se reescribe.
|
|
183
|
+
const written = [];
|
|
184
|
+
const unchanged = [];
|
|
185
|
+
const failed = [];
|
|
186
|
+
const outcomes = await sync.runPool(toDownload, CONCURRENCY, async (f) => {
|
|
187
|
+
const creds = await mgr.valid();
|
|
188
|
+
const read = await client.readFile(projectId, f.repoPath, creds);
|
|
189
|
+
const content = read.file && read.file.content != null ? read.file.content : '';
|
|
190
|
+
|
|
191
|
+
const current = sync.readLocalText(f.target);
|
|
192
|
+
if (current !== null && current === content) {
|
|
193
|
+
return { repoPath: f.repoPath, skipped: true };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
fs.mkdirSync(path.dirname(f.target), { recursive: true });
|
|
197
|
+
fs.writeFileSync(f.target, content, 'utf8');
|
|
198
|
+
return { repoPath: f.repoPath, skipped: false };
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
for (const res of outcomes) {
|
|
202
|
+
if (res && res.__error) {
|
|
203
|
+
failed.push({ repoPath: res.item.repoPath, reason: res.__error });
|
|
204
|
+
ui.error(`${res.item.repoPath} — ${res.__error}`);
|
|
205
|
+
} else if (res.skipped) {
|
|
206
|
+
unchanged.push(res.repoPath);
|
|
207
|
+
} else {
|
|
208
|
+
written.push(res.repoPath);
|
|
209
|
+
ui.success(`${ui.c.green('↓')} ${res.repoPath}`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
config.persistCredsIfChanged(cfg, mgr.current);
|
|
214
|
+
|
|
215
|
+
// 7) Binding + marca de sincronización para el `status` siguiente. Sólo si el
|
|
216
|
+
// pull cubrió todo: con conflictos pendientes la carpeta NO quedó alineada.
|
|
217
|
+
const complete = failed.length === 0 && (args.force || conflicting.length === 0);
|
|
218
|
+
try {
|
|
219
|
+
if (complete) {
|
|
220
|
+
remote.write(dir, { projectId, baseURL, lastSync: new Date().toISOString() });
|
|
221
|
+
} else if (written.length > 0) {
|
|
222
|
+
remote.write(dir, { projectId, baseURL });
|
|
223
|
+
}
|
|
224
|
+
} catch (e) {
|
|
225
|
+
ui.warn(`No se pudo escribir ${remote.REMOTE_FILENAME}: ${e.message}`);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// 8) Resumen.
|
|
229
|
+
ui.info('');
|
|
230
|
+
const tail = unchanged.length ? ` (${unchanged.length} ya estaban al día)` : '';
|
|
231
|
+
if (failed.length === 0) {
|
|
232
|
+
ui.success(`Pull completo: ${written.length} archivo(s) actualizados en "${dir}"${tail}.`);
|
|
233
|
+
return conflicting.length > 0 && !args.force ? 1 : 0;
|
|
234
|
+
}
|
|
235
|
+
ui.warn(`Pull con problemas: ${failed.length} archivo(s) fallaron, ${written.length} bajados${tail}.`);
|
|
236
|
+
return 1;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
module.exports = pull;
|
package/src/commands/push.js
CHANGED
|
@@ -6,6 +6,7 @@ const config = require('../config');
|
|
|
6
6
|
const remote = require('../remote');
|
|
7
7
|
const prompt = require('../prompt');
|
|
8
8
|
const ui = require('../ui');
|
|
9
|
+
const sync = require('../sync');
|
|
9
10
|
const { walk } = require('../walk');
|
|
10
11
|
const { classifyContent } = require('../textguard');
|
|
11
12
|
const { ShiloClient } = require('../ShiloClient');
|
|
@@ -69,6 +70,12 @@ async function push(args) {
|
|
|
69
70
|
const dryRun = !!args['dry-run'];
|
|
70
71
|
const message =
|
|
71
72
|
typeof args.message === 'string' && args.message.trim() ? args.message.trim() : null;
|
|
73
|
+
const card = typeof args.card === 'string' && args.card.trim() ? args.card.trim() : null;
|
|
74
|
+
|
|
75
|
+
if (card && card.length > 100) {
|
|
76
|
+
ui.error('El id de tarjeta (--card) no puede superar 100 caracteres.');
|
|
77
|
+
return 1;
|
|
78
|
+
}
|
|
72
79
|
|
|
73
80
|
// 3) Recorrer la carpeta.
|
|
74
81
|
const { files, skipped, ignored } = walk(dir);
|
|
@@ -131,40 +138,70 @@ async function push(args) {
|
|
|
131
138
|
|
|
132
139
|
// 5) Clasificar contra el remote (added / modified / unchanged) para planear.
|
|
133
140
|
// Esto ya toca la red (getFileMeta / readFile) pero no escribe nada.
|
|
134
|
-
|
|
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.
|
|
146
|
+
const mgr = sync.makeCredsManager(client, cfg);
|
|
135
147
|
const plan = { added: [], modified: [], unchanged: [], failed: [] };
|
|
136
148
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
creds = meta.credentials;
|
|
149
|
+
const planResults = await sync.runPool(uploads, sync.CONCURRENCY, async (u) => {
|
|
150
|
+
const creds = await mgr.valid();
|
|
151
|
+
const meta = await client.getFileMeta(projectId, u.relPath, creds);
|
|
141
152
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
if (meta.file && meta.file.type === 'folder') {
|
|
147
|
-
plan.failed.push({ ...u, reason: 'ya existe una carpeta con ese nombre' });
|
|
148
|
-
continue;
|
|
149
|
-
}
|
|
153
|
+
if (!meta.exists) return { kind: 'added', u };
|
|
154
|
+
if (meta.file && meta.file.type === 'folder') {
|
|
155
|
+
return { kind: 'failed', u, reason: 'ya existe una carpeta con ese nombre' };
|
|
156
|
+
}
|
|
150
157
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
continue;
|
|
159
|
-
}
|
|
158
|
+
// Existe como archivo: ver si cambió comparando tamaño y luego contenido.
|
|
159
|
+
const localSize = Buffer.byteLength(u.content, 'utf8');
|
|
160
|
+
if (meta.file && typeof meta.file.size === 'number' && meta.file.size === localSize) {
|
|
161
|
+
const readCreds = await mgr.valid();
|
|
162
|
+
const remoteRead = await client.readFile(projectId, u.relPath, readCreds);
|
|
163
|
+
if (remoteRead.file && remoteRead.file.content === u.content) {
|
|
164
|
+
return { kind: 'unchanged', u };
|
|
160
165
|
}
|
|
161
|
-
plan.modified.push({ ...u, remote: meta.file });
|
|
162
|
-
} catch (e) {
|
|
163
|
-
plan.failed.push({ ...u, reason: e.message });
|
|
164
166
|
}
|
|
167
|
+
return { kind: 'modified', u: { ...u, remote: meta.file } };
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
for (const r of planResults) {
|
|
171
|
+
if (r && r.__error) {
|
|
172
|
+
plan.failed.push({ ...r.item, reason: r.__error });
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (r.kind === 'failed') plan.failed.push({ ...r.u, reason: r.reason });
|
|
176
|
+
else plan[r.kind].push(r.u);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
config.persistCredsIfChanged(cfg, mgr.current);
|
|
180
|
+
|
|
181
|
+
// 5b) Atribución: para los archivos que vamos a pisar, quién los tocó último.
|
|
182
|
+
// Es lo que distingue "esto lo subí yo antes" de "esto lo va a perder un
|
|
183
|
+
// compañero". Best-effort: si el historial no se puede leer, seguimos.
|
|
184
|
+
let attribution = { index: new Map() };
|
|
185
|
+
const myId = cfg.credentials && cfg.credentials.user ? cfg.credentials.user.id : null;
|
|
186
|
+
if (plan.modified.length > 0) {
|
|
187
|
+
attribution = await sync.buildAuthorIndex(client, projectId, mgr, { commits: 50 });
|
|
188
|
+
config.persistCredsIfChanged(cfg, mgr.current);
|
|
165
189
|
}
|
|
166
190
|
|
|
167
|
-
|
|
191
|
+
/** Etiqueta " — última edición de X, fecha" para un archivo del plan. */
|
|
192
|
+
const authorNote = (u) => {
|
|
193
|
+
const meta = u.remote && attribution.index.get(u.remote.id);
|
|
194
|
+
if (!meta) return '';
|
|
195
|
+
const mine = myId && meta.authorId && meta.authorId === myId;
|
|
196
|
+
const who = mine ? ui.c.dim('vos') : ui.c.bold(meta.author);
|
|
197
|
+
return ui.c.dim(' — última edición de ') + who + ui.c.dim(`, ${sync.formatDate(meta.date)}`);
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
/** ¿El último que tocó este archivo fue otra persona? */
|
|
201
|
+
const touchedByOthers = plan.modified.filter((u) => {
|
|
202
|
+
const meta = u.remote && attribution.index.get(u.remote.id);
|
|
203
|
+
return meta && meta.authorId && myId && meta.authorId !== myId;
|
|
204
|
+
});
|
|
168
205
|
|
|
169
206
|
// 6) Reporte del plan.
|
|
170
207
|
ui.info('');
|
|
@@ -188,14 +225,38 @@ async function push(args) {
|
|
|
188
225
|
|
|
189
226
|
if (dryRun) {
|
|
190
227
|
for (const u of plan.added) ui.info(` ${ui.c.green('+')} ${u.relPath}`);
|
|
191
|
-
for (const u of plan.modified) ui.info(` ${ui.c.yellow('~')} ${u.relPath}`);
|
|
228
|
+
for (const u of plan.modified) ui.info(` ${ui.c.yellow('~')} ${u.relPath}${authorNote(u)}`);
|
|
192
229
|
for (const f of plan.failed) ui.info(` ${ui.c.red('!')} ${f.relPath} — ${f.reason}`);
|
|
193
230
|
ui.info('');
|
|
231
|
+
if (!myId && plan.modified.length > 0 && attribution.index.size > 0) {
|
|
232
|
+
ui.info(
|
|
233
|
+
ui.c.dim(
|
|
234
|
+
'(con token de acceso personal no sabemos tu id de usuario: ' +
|
|
235
|
+
'el autor se muestra, pero no se marca cuál sos vos)'
|
|
236
|
+
)
|
|
237
|
+
);
|
|
238
|
+
ui.info('');
|
|
239
|
+
}
|
|
240
|
+
if (touchedByOthers.length > 0) {
|
|
241
|
+
ui.warn(
|
|
242
|
+
`${touchedByOthers.length} de esos archivos los editó otra persona: ` +
|
|
243
|
+
`este push pisaría su versión.`
|
|
244
|
+
);
|
|
245
|
+
ui.info(ui.c.dim('Revisalos con "shilo diff <archivo>" o traelos con "shilo pull".'));
|
|
246
|
+
ui.info('');
|
|
247
|
+
}
|
|
194
248
|
ui.info(ui.c.dim('(--dry-run: no se subió nada)'));
|
|
195
249
|
return plan.failed.length ? 1 : 0;
|
|
196
250
|
}
|
|
197
251
|
|
|
198
|
-
// 7) Confirmar (salvo --yes).
|
|
252
|
+
// 7) Confirmar (salvo --yes). Si vamos a pisar trabajo ajeno, lo decimos
|
|
253
|
+
// antes de preguntar: es el caso que motivó todo este flujo.
|
|
254
|
+
if (touchedByOthers.length > 0) {
|
|
255
|
+
ui.warn(`Vas a pisar ${touchedByOthers.length} archivo(s) editado(s) por otra persona:`);
|
|
256
|
+
for (const u of touchedByOthers) ui.info(` ${ui.c.red('!')} ${u.relPath}${authorNote(u)}`);
|
|
257
|
+
ui.info('');
|
|
258
|
+
}
|
|
259
|
+
|
|
199
260
|
if (!args.yes) {
|
|
200
261
|
const ok = await prompt.confirm(`¿Subir ${toWrite} archivo(s) al proyecto?`, true);
|
|
201
262
|
if (!ok) {
|
|
@@ -209,57 +270,94 @@ async function push(args) {
|
|
|
209
270
|
const ensuredCache = new Set();
|
|
210
271
|
const failedWrites = [];
|
|
211
272
|
|
|
273
|
+
// 8a) Asegurar carpetas primero, EN SERIE. `ensuredCache` evita re-listar una
|
|
274
|
+
// carpeta ya confirmada, pero además evita la carrera real: el backend no
|
|
275
|
+
// siempre responde 409 de forma confiable si dos requests intentan crear
|
|
276
|
+
// la misma carpeta al mismo tiempo, así que esta fase no se paraleliza.
|
|
277
|
+
// Como la mayoría de los archivos comparten carpetas, el cache hace que
|
|
278
|
+
// esto sea barato (proporcional a carpetas únicas, no a archivos).
|
|
279
|
+
const ensureFailed = new Set();
|
|
212
280
|
for (const u of plan.added) {
|
|
213
281
|
try {
|
|
214
|
-
creds = await
|
|
215
|
-
|
|
216
|
-
projectId, u.relPath, u.content, u.mimeType, creds
|
|
217
|
-
);
|
|
218
|
-
creds = created.credentials;
|
|
219
|
-
|
|
220
|
-
if (created.created && created.file) {
|
|
221
|
-
changes.push({
|
|
222
|
-
file_id: created.file.id,
|
|
223
|
-
action: 'added',
|
|
224
|
-
new_storage_path: created.file.storage_path,
|
|
225
|
-
});
|
|
226
|
-
ui.success(`${ui.c.green('+')} ${u.relPath}`);
|
|
227
|
-
} else {
|
|
228
|
-
// 409: apareció entremedio; caemos a update.
|
|
229
|
-
const upd = await client.writeFile(projectId, u.relPath, u.content, u.mimeType, creds);
|
|
230
|
-
creds = upd.credentials;
|
|
231
|
-
changes.push({
|
|
232
|
-
file_id: upd.file.id,
|
|
233
|
-
action: 'modified',
|
|
234
|
-
previous_storage_path: (created.file && created.file.storage_path) || upd.file.storage_path,
|
|
235
|
-
new_storage_path: upd.file.storage_path,
|
|
236
|
-
});
|
|
237
|
-
ui.success(`${ui.c.yellow('~')} ${u.relPath}`);
|
|
238
|
-
}
|
|
239
|
-
config.persistCredsIfChanged(cfg, creds);
|
|
282
|
+
const creds = await mgr.valid();
|
|
283
|
+
await client.ensurePath(projectId, u.relPath, creds, ensuredCache);
|
|
240
284
|
} catch (e) {
|
|
285
|
+
ensureFailed.add(u.relPath);
|
|
241
286
|
failedWrites.push({ relPath: u.relPath, reason: e.message });
|
|
242
287
|
ui.error(`${u.relPath} — ${e.message}`);
|
|
243
288
|
}
|
|
244
289
|
}
|
|
290
|
+
config.persistCredsIfChanged(cfg, mgr.current);
|
|
291
|
+
|
|
292
|
+
// 8b) Subidas propiamente dichas, en paralelo: acá es donde vive el grueso
|
|
293
|
+
// del tiempo (un request por archivo), y a diferencia de ensurePath no
|
|
294
|
+
// hay riesgo de carrera porque cada archivo tiene una ruta propia.
|
|
295
|
+
const addedReady = plan.added.filter((u) => !ensureFailed.has(u.relPath));
|
|
296
|
+
const addedResults = await sync.runPool(addedReady, sync.CONCURRENCY, async (u) => {
|
|
297
|
+
const creds = await mgr.valid();
|
|
298
|
+
const created = await client.createFile(projectId, u.relPath, u.content, u.mimeType, creds);
|
|
299
|
+
|
|
300
|
+
if (created.created && created.file) {
|
|
301
|
+
return {
|
|
302
|
+
u,
|
|
303
|
+
symbol: '+',
|
|
304
|
+
change: {
|
|
305
|
+
file_id: created.file.id,
|
|
306
|
+
action: 'added',
|
|
307
|
+
new_storage_path: created.file.storage_path,
|
|
308
|
+
},
|
|
309
|
+
};
|
|
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
|
+
});
|
|
245
325
|
|
|
246
|
-
for (const
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
326
|
+
for (const r of addedResults) {
|
|
327
|
+
if (r && r.__error) {
|
|
328
|
+
failedWrites.push({ relPath: r.item.relPath, reason: r.__error });
|
|
329
|
+
ui.error(`${r.item.relPath} — ${r.__error}`);
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
changes.push(r.change);
|
|
333
|
+
ui.success(`${ui.c[r.symbol === '+' ? 'green' : 'yellow'](r.symbol)} ${r.u.relPath}`);
|
|
334
|
+
}
|
|
335
|
+
config.persistCredsIfChanged(cfg, mgr.current);
|
|
336
|
+
|
|
337
|
+
const modifiedResults = await sync.runPool(plan.modified, sync.CONCURRENCY, async (u) => {
|
|
338
|
+
const creds = await mgr.valid();
|
|
339
|
+
const upd = await client.writeFile(projectId, u.relPath, u.content, u.mimeType, creds);
|
|
340
|
+
return {
|
|
341
|
+
u,
|
|
342
|
+
change: {
|
|
251
343
|
file_id: upd.file.id,
|
|
252
344
|
action: 'modified',
|
|
253
345
|
previous_storage_path: u.remote ? u.remote.storage_path : undefined,
|
|
254
346
|
new_storage_path: upd.file.storage_path,
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
347
|
+
},
|
|
348
|
+
};
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
for (const r of modifiedResults) {
|
|
352
|
+
if (r && r.__error) {
|
|
353
|
+
failedWrites.push({ relPath: r.item.relPath, reason: r.__error });
|
|
354
|
+
ui.error(`${r.item.relPath} — ${r.__error}`);
|
|
355
|
+
continue;
|
|
261
356
|
}
|
|
357
|
+
changes.push(r.change);
|
|
358
|
+
ui.success(`${ui.c.yellow('~')} ${r.u.relPath}`);
|
|
262
359
|
}
|
|
360
|
+
config.persistCredsIfChanged(cfg, mgr.current);
|
|
263
361
|
|
|
264
362
|
// 9) Commit(s) en lotes de <=100.
|
|
265
363
|
let committed = 0;
|
|
@@ -269,12 +367,12 @@ async function push(args) {
|
|
|
269
367
|
for (let i = 0; i < total; i += COMMIT_BATCH) {
|
|
270
368
|
const batch = changes.slice(i, i + COMMIT_BATCH);
|
|
271
369
|
const partNo = Math.floor(i / COMMIT_BATCH) + 1;
|
|
272
|
-
const msg = buildMessage(message, batch.length, total, partNo, nBatches);
|
|
370
|
+
const msg = buildMessage(message, batch.length, total, partNo, nBatches, card);
|
|
273
371
|
try {
|
|
274
|
-
const
|
|
275
|
-
|
|
372
|
+
const creds = await mgr.valid();
|
|
373
|
+
await client.createCommit(projectId, msg, batch, creds);
|
|
276
374
|
committed += batch.length;
|
|
277
|
-
config.persistCredsIfChanged(cfg,
|
|
375
|
+
config.persistCredsIfChanged(cfg, mgr.current);
|
|
278
376
|
} catch (e) {
|
|
279
377
|
ui.error(`Fallo al crear el commit (lote ${partNo}/${nBatches}): ${e.message}`);
|
|
280
378
|
failedWrites.push({ relPath: `<commit lote ${partNo}>`, reason: e.message });
|
|
@@ -282,9 +380,15 @@ async function push(args) {
|
|
|
282
380
|
}
|
|
283
381
|
}
|
|
284
382
|
|
|
285
|
-
config.persistCredsIfChanged(cfg,
|
|
383
|
+
config.persistCredsIfChanged(cfg, mgr.current);
|
|
384
|
+
|
|
385
|
+
// 10) Anotar la sincronización: `status` la usa después para saber qué del
|
|
386
|
+
// remoto es posterior a lo último que subimos.
|
|
387
|
+
if (failedWrites.length === 0) {
|
|
388
|
+
remote.touchSync(dir, { projectId, baseURL });
|
|
389
|
+
}
|
|
286
390
|
|
|
287
|
-
//
|
|
391
|
+
// 11) Resumen final.
|
|
288
392
|
ui.info('');
|
|
289
393
|
const nCommits = Math.ceil(changes.length / COMMIT_BATCH);
|
|
290
394
|
if (failedWrites.length === 0) {
|
|
@@ -297,10 +401,21 @@ async function push(args) {
|
|
|
297
401
|
return 1;
|
|
298
402
|
}
|
|
299
403
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
404
|
+
/**
|
|
405
|
+
* Arma el mensaje del commit. Si vino `--card`, se agrega como trailer
|
|
406
|
+
* `[card: <id>]` al final: queda visible en el historial y es fácil de grepear
|
|
407
|
+
* para cruzar un cambio con la tarjeta que lo pidió (Metrónomo u otro tablero).
|
|
408
|
+
* El backend corta en 500 caracteres, así que recortamos el mensaje del usuario
|
|
409
|
+
* antes que el trailer.
|
|
410
|
+
*/
|
|
411
|
+
function buildMessage(userMsg, batchCount, total, partNo, nBatches, card) {
|
|
412
|
+
let base = userMsg || `shilo push: ${total} archivo(s)`;
|
|
413
|
+
if (nBatches > 1) base = `${base} (${partNo}/${nBatches})`;
|
|
414
|
+
if (!card) return base.slice(0, 500);
|
|
415
|
+
|
|
416
|
+
const trailer = ` [card: ${card}]`;
|
|
417
|
+
const room = 500 - trailer.length;
|
|
418
|
+
return `${base.slice(0, room)}${trailer}`;
|
|
304
419
|
}
|
|
305
420
|
|
|
306
421
|
module.exports = push;
|