shilo-cli 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +319 -255
- package/bin/shilo.js +197 -165
- package/package.json +1 -1
- package/src/ShiloClient.js +1027 -837
- package/src/args.js +32 -8
- package/src/commands/diff.js +18 -2
- package/src/commands/export.js +226 -0
- package/src/commands/log.js +4 -0
- package/src/commands/pull.js +86 -2
- package/src/commands/push.js +233 -8
- package/src/commands/review.js +101 -0
- package/src/commands/status.js +36 -0
- package/src/exportgit.js +417 -0
- package/src/gitobjects.js +532 -0
- package/src/sync.js +4 -1
- package/src/walk.js +30 -0
package/src/args.js
CHANGED
|
@@ -10,14 +10,32 @@
|
|
|
10
10
|
* -m "msg" -> alias corto (ver `aliases`)
|
|
11
11
|
* positionals -> array de argumentos sueltos
|
|
12
12
|
*
|
|
13
|
+
* Las claves de `multiple` acumulan en un array en vez de pisarse, y quedan
|
|
14
|
+
* SIEMPRE como array (aunque haya una sola aparición, o ninguna): así el
|
|
15
|
+
* consumidor no tiene que distinguir "uno" de "varios", que es justo donde se
|
|
16
|
+
* cuelan los bugs de `--only`.
|
|
17
|
+
*
|
|
13
18
|
* Devuelve { _: [positionals...], ...flags }.
|
|
14
19
|
*/
|
|
15
|
-
function parseArgs(argv, { booleans = [], aliases = {} } = {}) {
|
|
20
|
+
function parseArgs(argv, { booleans = [], aliases = {}, multiple = [] } = {}) {
|
|
16
21
|
const out = { _: [] };
|
|
17
22
|
const boolSet = new Set(booleans);
|
|
23
|
+
const multiSet = new Set(multiple);
|
|
18
24
|
|
|
19
25
|
const canonical = (k) => aliases[k] || k;
|
|
20
26
|
|
|
27
|
+
const set = (key, value) => {
|
|
28
|
+
if (!multiSet.has(key)) {
|
|
29
|
+
out[key] = value;
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
// `--only` sin valor (`--only --yes`) no es una lista de nada: se descarta
|
|
33
|
+
// en vez de meter un `true` que el consumidor tendría que filtrar.
|
|
34
|
+
if (value === true) return;
|
|
35
|
+
if (!Array.isArray(out[key])) out[key] = [];
|
|
36
|
+
out[key].push(value);
|
|
37
|
+
};
|
|
38
|
+
|
|
21
39
|
for (let i = 0; i < argv.length; i++) {
|
|
22
40
|
let token = argv[i];
|
|
23
41
|
|
|
@@ -37,17 +55,17 @@ function parseArgs(argv, { booleans = [], aliases = {} } = {}) {
|
|
|
37
55
|
}
|
|
38
56
|
key = canonical(key);
|
|
39
57
|
if (value !== undefined) {
|
|
40
|
-
|
|
58
|
+
set(key, value);
|
|
41
59
|
} else if (boolSet.has(key)) {
|
|
42
|
-
|
|
60
|
+
set(key, true);
|
|
43
61
|
} else {
|
|
44
62
|
const next = argv[i + 1];
|
|
45
63
|
// `-` a secas es un valor legítimo ("leer de stdin"), no otra bandera.
|
|
46
64
|
if (next !== undefined && (next === '-' || !next.startsWith('-'))) {
|
|
47
|
-
|
|
65
|
+
set(key, next);
|
|
48
66
|
i++;
|
|
49
67
|
} else {
|
|
50
|
-
|
|
68
|
+
set(key, true);
|
|
51
69
|
}
|
|
52
70
|
}
|
|
53
71
|
continue;
|
|
@@ -56,15 +74,15 @@ function parseArgs(argv, { booleans = [], aliases = {} } = {}) {
|
|
|
56
74
|
if (token.startsWith('-') && token.length > 1) {
|
|
57
75
|
const key = canonical(token.slice(1));
|
|
58
76
|
if (boolSet.has(key)) {
|
|
59
|
-
|
|
77
|
+
set(key, true);
|
|
60
78
|
} else {
|
|
61
79
|
const next = argv[i + 1];
|
|
62
80
|
// `-` a secas es un valor legítimo ("leer de stdin"), no otra bandera.
|
|
63
81
|
if (next !== undefined && (next === '-' || !next.startsWith('-'))) {
|
|
64
|
-
|
|
82
|
+
set(key, next);
|
|
65
83
|
i++;
|
|
66
84
|
} else {
|
|
67
|
-
|
|
85
|
+
set(key, true);
|
|
68
86
|
}
|
|
69
87
|
}
|
|
70
88
|
continue;
|
|
@@ -73,6 +91,12 @@ function parseArgs(argv, { booleans = [], aliases = {} } = {}) {
|
|
|
73
91
|
out._.push(token);
|
|
74
92
|
}
|
|
75
93
|
|
|
94
|
+
// Normalización: una clave `multiple` que no apareció igual llega como array
|
|
95
|
+
// vacío, para que el consumidor pueda hacer `.length` sin chequear antes.
|
|
96
|
+
for (const key of multiSet) {
|
|
97
|
+
if (!Array.isArray(out[key])) out[key] = [];
|
|
98
|
+
}
|
|
99
|
+
|
|
76
100
|
return out;
|
|
77
101
|
}
|
|
78
102
|
|
package/src/commands/diff.js
CHANGED
|
@@ -127,16 +127,32 @@ async function diffAll({ dir, projectId, baseURL, cfg, client, mgr }) {
|
|
|
127
127
|
}
|
|
128
128
|
|
|
129
129
|
const cmp = sync.compareTrees(local.files, tree.files);
|
|
130
|
-
|
|
130
|
+
|
|
131
|
+
// Un archivo ignorado localmente no es una diferencia que haya que resolver:
|
|
132
|
+
// el push no lo sube y el pull no lo baja. Se nombra aparte, no como "sólo
|
|
133
|
+
// remoto", que invita a un pull que igual no lo traería.
|
|
134
|
+
const isIgnoredPath = sync.makePathMatcher(dir);
|
|
135
|
+
const onlyRemote = cmp.onlyRemote.filter((i) => !isIgnoredPath(i.repoPath));
|
|
136
|
+
const ignoredRemote = cmp.onlyRemote.filter((i) => isIgnoredPath(i.repoPath));
|
|
137
|
+
|
|
138
|
+
const total = cmp.onlyLocal.length + onlyRemote.length + cmp.differing.length;
|
|
131
139
|
|
|
132
140
|
ui.info('');
|
|
133
141
|
if (total === 0) {
|
|
134
142
|
ui.success('Sin diferencias de tamaño ni de archivos entre local y remoto.');
|
|
143
|
+
if (ignoredRemote.length > 0) {
|
|
144
|
+
ui.info(
|
|
145
|
+
ui.c.dim(`(${ignoredRemote.length} archivo(s) del remoto están ignorados localmente.)`)
|
|
146
|
+
);
|
|
147
|
+
}
|
|
135
148
|
return 0;
|
|
136
149
|
}
|
|
137
150
|
|
|
138
151
|
for (const item of cmp.onlyLocal) ui.info(` ${ui.c.green('+')} ${item.repoPath} ${ui.c.dim('(sólo local)')}`);
|
|
139
|
-
for (const item of
|
|
152
|
+
for (const item of onlyRemote) ui.info(` ${ui.c.red('-')} ${item.repoPath} ${ui.c.dim('(sólo remoto)')}`);
|
|
153
|
+
for (const item of ignoredRemote) {
|
|
154
|
+
ui.info(` ${ui.c.dim('·')} ${ui.c.dim(`${item.repoPath} (sólo remoto, ignorado localmente)`)}`);
|
|
155
|
+
}
|
|
140
156
|
for (const item of cmp.differing) {
|
|
141
157
|
const delta = item.local.size - item.remote.size;
|
|
142
158
|
const sign = delta > 0 ? `+${delta}` : String(delta);
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const config = require('../config');
|
|
5
|
+
const remote = require('../remote');
|
|
6
|
+
const ui = require('../ui');
|
|
7
|
+
const sync = require('../sync');
|
|
8
|
+
const { ShiloClient } = require('../ShiloClient');
|
|
9
|
+
const { DEFAULT_BASE_URL } = require('../context');
|
|
10
|
+
const { exportarProyecto, leerMapa } = require('../exportgit');
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* shilo export <carpeta> [--project <uuid>] [--base-url <url>] [--json]
|
|
14
|
+
*
|
|
15
|
+
* Escribe la historia del proyecto como un repositorio Git en `carpeta`, con
|
|
16
|
+
* `main` y `refs/notes/shilo`. La primera vez la carpeta tiene que estar vacía
|
|
17
|
+
* o no existir; después, repetir el comando sobre la misma carpeta trae sólo
|
|
18
|
+
* lo nuevo. No toca el proyecto: es de sólo lectura.
|
|
19
|
+
*
|
|
20
|
+
* El proyecto sale de `--project`, de la exportación anterior que haya en la
|
|
21
|
+
* carpeta, o del `.shilo.json` de la carpeta actual, en ese orden. Un proyecto
|
|
22
|
+
* público se exporta sin sesión.
|
|
23
|
+
*/
|
|
24
|
+
async function exportCmd(args) {
|
|
25
|
+
const destinoArg = typeof args._[0] === 'string' ? args._[0] : null;
|
|
26
|
+
if (!destinoArg) {
|
|
27
|
+
ui.error('Falta la carpeta de destino: shilo export <carpeta>.');
|
|
28
|
+
return 1;
|
|
29
|
+
}
|
|
30
|
+
const destino = path.resolve(destinoArg);
|
|
31
|
+
const asJson = !!args.json;
|
|
32
|
+
|
|
33
|
+
let previo = null;
|
|
34
|
+
let atada = null;
|
|
35
|
+
try {
|
|
36
|
+
previo = leerMapa(path.join(destino, '.git'));
|
|
37
|
+
atada = remote.read(process.cwd());
|
|
38
|
+
} catch (e) {
|
|
39
|
+
ui.error(e.message);
|
|
40
|
+
return 1;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const pedido = typeof args.project === 'string' && args.project.trim() ? args.project.trim() : null;
|
|
44
|
+
if (pedido && previo && previo.projectId !== pedido) {
|
|
45
|
+
ui.error(`${destino} ya tiene la exportación de otro proyecto (${previo.projectId}).`);
|
|
46
|
+
ui.info('Exportá a una carpeta nueva.');
|
|
47
|
+
return 1;
|
|
48
|
+
}
|
|
49
|
+
const projectId = pedido || (previo && previo.projectId) || (atada && atada.projectId) || null;
|
|
50
|
+
if (!projectId) {
|
|
51
|
+
ui.error('No sé qué proyecto exportar.');
|
|
52
|
+
ui.info('Pasá --project <uuid>, o corré el comando dentro de una carpeta atada con "shilo init".');
|
|
53
|
+
return 1;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const cfg = config.load() || {};
|
|
57
|
+
const baseURL = args['base-url'] || (previo && previo.baseURL) || (atada && atada.baseURL) || cfg.baseURL || DEFAULT_BASE_URL;
|
|
58
|
+
const client = new ShiloClient({
|
|
59
|
+
baseURL,
|
|
60
|
+
supabaseUrl: cfg.supabaseUrl || null,
|
|
61
|
+
supabaseAnonKey: cfg.supabaseAnonKey || null,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Sin sesión se intenta igual: un proyecto público se exporta sin cuenta.
|
|
65
|
+
let creds = null;
|
|
66
|
+
if (cfg.credentials) {
|
|
67
|
+
try {
|
|
68
|
+
creds = await sync.makeCredsManager(client, cfg).valid();
|
|
69
|
+
} catch (e) {
|
|
70
|
+
ui.error(`La sesión guardada no sirve: ${e.message}`);
|
|
71
|
+
ui.info('Corré "shilo login" de nuevo.');
|
|
72
|
+
return 1;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let progreso = null;
|
|
77
|
+
if (!asJson) {
|
|
78
|
+
ui.info(`${previo ? 'Actualizando la exportación' : 'Exportando'} de ${ui.c.bold(projectId)} en ${ui.c.bold(destino)}`);
|
|
79
|
+
ui.info(ui.c.dim(`Servidor: ${baseURL}`));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let informe;
|
|
83
|
+
try {
|
|
84
|
+
informe = await exportarProyecto({
|
|
85
|
+
client,
|
|
86
|
+
projectId,
|
|
87
|
+
creds,
|
|
88
|
+
destino,
|
|
89
|
+
baseURL,
|
|
90
|
+
onEtapa: (nombre, detalle) => {
|
|
91
|
+
if (asJson) return;
|
|
92
|
+
if (nombre === 'archivos' && detalle > 0) {
|
|
93
|
+
ui.info(`Bajando ${detalle} archivo(s)…`);
|
|
94
|
+
progreso = ui.progress(detalle, 'archivos');
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
onProgreso: (hechos) => { if (progreso) progreso.update(hechos); },
|
|
98
|
+
});
|
|
99
|
+
} catch (e) {
|
|
100
|
+
if (progreso) progreso.clear();
|
|
101
|
+
return explicarError(e, { destino, projectId, conSesion: Boolean(creds), asJson });
|
|
102
|
+
} finally {
|
|
103
|
+
if (creds) config.persistCredsIfChanged(cfg, creds);
|
|
104
|
+
}
|
|
105
|
+
if (progreso) progreso.clear();
|
|
106
|
+
if (informe.credentials) config.persistCredsIfChanged(cfg, informe.credentials);
|
|
107
|
+
|
|
108
|
+
if (asJson) {
|
|
109
|
+
const { credentials, ...resto } = informe;
|
|
110
|
+
console.log(JSON.stringify({ projectId, ...resto }, null, 2));
|
|
111
|
+
return 0;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
mostrarInforme(informe);
|
|
115
|
+
return 0;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function mostrarInforme(inf) {
|
|
119
|
+
ui.info('');
|
|
120
|
+
if (inf.incremental && inf.mainAntes === inf.main) {
|
|
121
|
+
ui.success('Ya estaba al día: no hay puntos de guardado nuevos.');
|
|
122
|
+
} else if (inf.incremental) {
|
|
123
|
+
ui.success(`${inf.nuevos} punto(s) de guardado nuevo(s); ${inf.commits} en total.`);
|
|
124
|
+
} else {
|
|
125
|
+
ui.success(`${inf.commits} punto(s) de guardado exportado(s).`);
|
|
126
|
+
}
|
|
127
|
+
ui.info(` main ${ui.c.yellow(inf.main.slice(0, 12))}`);
|
|
128
|
+
if (inf.notas) ui.info(` procedencia ${ui.c.yellow(inf.notas.slice(0, 12))} ${ui.c.dim('(refs/notes/shilo)')}`);
|
|
129
|
+
ui.info(ui.c.dim(` ${inf.archivos} archivo(s) y ${inf.carpetas} carpeta(s) bajados, ${formatoBytes(inf.bytes)}.`));
|
|
130
|
+
|
|
131
|
+
if (inf.vacias.length > 0) {
|
|
132
|
+
ui.info('');
|
|
133
|
+
ui.warn(`Git no guarda carpetas vacías; quedaron afuera ${inf.vacias.length}:`);
|
|
134
|
+
for (const r of inf.vacias.slice(0, 20)) ui.info(` ${r}/`);
|
|
135
|
+
if (inf.vacias.length > 20) ui.info(ui.c.dim(` … y ${inf.vacias.length - 20} más.`));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (!inf.carpetaActualizada) {
|
|
139
|
+
ui.info('');
|
|
140
|
+
ui.info(ui.c.dim('La carpeta no está en main: se movió la rama pero no se tocaron tus archivos.'));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
ui.info('');
|
|
144
|
+
ui.info('Para subirlo a otro servidor Git:');
|
|
145
|
+
ui.info(ui.c.cyan(` git -C "${inf.destino}" push <remoto> main`));
|
|
146
|
+
if (inf.notas) {
|
|
147
|
+
// La ref de notas se rehace entera en cada exportación (no tiene padre),
|
|
148
|
+
// así que un push normal la rechaza a partir de la segunda: va con `+`.
|
|
149
|
+
ui.info(ui.c.cyan(` git -C "${inf.destino}" push <remoto> +refs/notes/shilo`));
|
|
150
|
+
if (inf.notasReemplazadas) {
|
|
151
|
+
ui.info(ui.c.dim(' La procedencia se reemplazó entera; por eso el + en el push.'));
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function explicarError(e, { destino, projectId, conSesion, asJson }) {
|
|
157
|
+
const code = e && e.code;
|
|
158
|
+
if (asJson) {
|
|
159
|
+
console.log(JSON.stringify({
|
|
160
|
+
ok: false, code: code || null, status: (e && e.status) || null, message: e.message, path: e.path || null,
|
|
161
|
+
}, null, 2));
|
|
162
|
+
return 1;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
ui.error(e.message);
|
|
166
|
+
switch (code) {
|
|
167
|
+
case 'SIN_GIT':
|
|
168
|
+
ui.info('Instalá Git (https://git-scm.com) y volvé a correr el comando.');
|
|
169
|
+
break;
|
|
170
|
+
case 'DESTINO_OCUPADO':
|
|
171
|
+
ui.info('La primera exportación va a una carpeta vacía o que no existe.');
|
|
172
|
+
break;
|
|
173
|
+
case 'OTRO_PROYECTO':
|
|
174
|
+
ui.info('Exportá a una carpeta nueva.');
|
|
175
|
+
break;
|
|
176
|
+
case 'MAIN_AJENA':
|
|
177
|
+
ui.info('Hay puntos de guardado en main que no vienen de Shilo. Llevalos a otra rama');
|
|
178
|
+
ui.info('(git branch <nombre> main) y devolvé main a lo exportado, o exportá a una carpeta nueva.');
|
|
179
|
+
break;
|
|
180
|
+
case 'CAMBIOS_LOCALES':
|
|
181
|
+
ui.info('Guardalos o descartalos (git stash) antes de actualizar la exportación.');
|
|
182
|
+
break;
|
|
183
|
+
case 'MAPA_ILEGIBLE':
|
|
184
|
+
case 'REPO_INCOMPLETO':
|
|
185
|
+
ui.info('La exportación anterior está dañada. Exportá a una carpeta nueva.');
|
|
186
|
+
break;
|
|
187
|
+
case 'SIN_IDENTIDADES':
|
|
188
|
+
ui.info('Hay que aplicar la migración 018 en el servidor antes de exportar.');
|
|
189
|
+
break;
|
|
190
|
+
case 'PROYECTO_VACIO':
|
|
191
|
+
ui.info('Guardá una versión primero.');
|
|
192
|
+
break;
|
|
193
|
+
case 'NOMBRE_INVALIDO':
|
|
194
|
+
ui.info(`Git no acepta «${e.path || e.name}». Renombralo en Shilo y exportá de nuevo.`);
|
|
195
|
+
break;
|
|
196
|
+
case 'SHA1_MOVIDO':
|
|
197
|
+
ui.info('Lo ya exportado daría otro resultado: es un error de Shilo, no de tu carpeta. Avisanos.');
|
|
198
|
+
break;
|
|
199
|
+
case 'GRAFO_INCOMPLETO':
|
|
200
|
+
case 'BLOB_CORRUPTO':
|
|
201
|
+
case 'ARBOL_CORRUPTO':
|
|
202
|
+
case 'FALTA_OBJETO':
|
|
203
|
+
ui.info('A la historia del proyecto le falta algo en el servidor. No se escribió ninguna rama.');
|
|
204
|
+
break;
|
|
205
|
+
default:
|
|
206
|
+
if (e && e.status === 404) {
|
|
207
|
+
ui.info(conSesion
|
|
208
|
+
? `El proyecto ${projectId} no existe o no tenés acceso.`
|
|
209
|
+
: 'Si el proyecto es privado, iniciá sesión con "shilo login".');
|
|
210
|
+
} else if (e && e.status === 401) {
|
|
211
|
+
ui.info('Corré "shilo login" de nuevo.');
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (code && code !== 'DESTINO_OCUPADO' && code !== 'SIN_GIT' && code !== 'OTRO_PROYECTO') {
|
|
215
|
+
ui.info(ui.c.dim(`Carpeta: ${destino}`));
|
|
216
|
+
}
|
|
217
|
+
return 1;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function formatoBytes(n) {
|
|
221
|
+
if (n < 1024) return `${n} B`;
|
|
222
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
|
223
|
+
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
module.exports = exportCmd;
|
package/src/commands/log.js
CHANGED
|
@@ -4,6 +4,7 @@ const config = require('../config');
|
|
|
4
4
|
const ui = require('../ui');
|
|
5
5
|
const sync = require('../sync');
|
|
6
6
|
const { resolveContext } = require('../context');
|
|
7
|
+
const { marcaDeRevision } = require('./review');
|
|
7
8
|
|
|
8
9
|
const DEFAULT_LIMIT = 20;
|
|
9
10
|
|
|
@@ -90,6 +91,7 @@ async function log(args) {
|
|
|
90
91
|
message: c.message,
|
|
91
92
|
author: sync.describeAuthor(c.users),
|
|
92
93
|
date: c.created_at,
|
|
94
|
+
reviews: Array.isArray(c.reviews) ? c.reviews : [],
|
|
93
95
|
files: (c.files || []).map((cf) => ({
|
|
94
96
|
name: cf.files ? cf.files.name : null,
|
|
95
97
|
action: cf.action,
|
|
@@ -122,6 +124,8 @@ async function log(args) {
|
|
|
122
124
|
const hash = commit.hash ? String(commit.hash).slice(0, 8) : '(sin hash)';
|
|
123
125
|
ui.info(`${ui.c.yellow(hash)} ${ui.c.bold(sync.describeAuthor(commit.users))} ${ui.c.dim(sync.formatDate(commit.created_at))}`);
|
|
124
126
|
ui.info(` ${commit.message}`);
|
|
127
|
+
const marca = marcaDeRevision(commit.reviews);
|
|
128
|
+
if (marca) ui.info(` ${ui.c.dim('revisión:')} ${marca}`);
|
|
125
129
|
if (showFiles && Array.isArray(commit.files) && commit.files.length > 0) {
|
|
126
130
|
const shown = repoPath
|
|
127
131
|
? commit.files.filter((cf) => cf.files && cf.files.id === targetId)
|
package/src/commands/pull.js
CHANGED
|
@@ -40,6 +40,38 @@ function readLocalBytes(absPath) {
|
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Carpeta de respaldo de esta corrida: `.shilo/respaldo/<ISO>/`.
|
|
45
|
+
*
|
|
46
|
+
* Va adentro de `.shilo/` —que está en DEFAULT_IGNORES— y no como un
|
|
47
|
+
* `<archivo>.local` al lado: un hermano se vuelve un archivo más del árbol y el
|
|
48
|
+
* push siguiente lo sube, o sea que cambiaríamos "perder trabajo" por "publicar
|
|
49
|
+
* tu versión en conflicto". Al ir por timestamp, dos pulls seguidos no se pisan.
|
|
50
|
+
*
|
|
51
|
+
* Los `:` del ISO no son válidos en nombres de archivo de Windows.
|
|
52
|
+
*/
|
|
53
|
+
function backupDirFor(dir, stamp) {
|
|
54
|
+
return path.join(dir, '.shilo', 'respaldo', stamp.replace(/:/g, '-'));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Copia la versión local de `repoPath` al respaldo antes de pisarla. Devuelve
|
|
59
|
+
* true si quedó copiada. Es best-effort: si falla, el pull sigue —avisando—
|
|
60
|
+
* porque abortar a mitad del pool dejaría la carpeta peor que como estaba.
|
|
61
|
+
*/
|
|
62
|
+
function backupLocal(baseDir, absPath, repoPath) {
|
|
63
|
+
try {
|
|
64
|
+
if (!fs.existsSync(absPath) || !fs.statSync(absPath).isFile()) return false;
|
|
65
|
+
const target = path.join(baseDir, ...repoPath.split('/'));
|
|
66
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
67
|
+
fs.copyFileSync(absPath, target);
|
|
68
|
+
return true;
|
|
69
|
+
} catch (e) {
|
|
70
|
+
ui.warn(`no se pudo respaldar ${ui.c.dim(repoPath)} — ${e.message}`);
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
43
75
|
/**
|
|
44
76
|
* shilo pull [carpeta] [--project <uuid>] [--base-url <url>] [--dry-run] [--yes] [--force]
|
|
45
77
|
*
|
|
@@ -101,14 +133,27 @@ async function pull(args) {
|
|
|
101
133
|
// devuelve una URL firmada en vez de contenido, y se bajan como bytes. Lo
|
|
102
134
|
// que se saltaba antes no era una limitación del formato sino del camino:
|
|
103
135
|
// el contenido viajaba como texto y un PNG volvía mutilado.
|
|
136
|
+
//
|
|
137
|
+
// Lo ignorado localmente tampoco se baja: si `.env.*` está en el ignore,
|
|
138
|
+
// que alguien haya subido un `.env.ejemplo` desde otra máquina no es razón
|
|
139
|
+
// para que aparezca acá. Además arregla el conteo: antes entraban en
|
|
140
|
+
// "nuevos" (walkLocal no los ve) y después se descartaban al descargar.
|
|
141
|
+
const includeIgnored = !!args['include-ignored'];
|
|
142
|
+
const isIgnoredPath = sync.makePathMatcher(dir);
|
|
143
|
+
|
|
104
144
|
const candidates = [];
|
|
105
145
|
const unsafe = [];
|
|
146
|
+
const ignoredRemote = [];
|
|
106
147
|
for (const f of tree.files) {
|
|
107
148
|
const safe = safeTarget(dir, f.repoPath);
|
|
108
149
|
if (!safe.ok) {
|
|
109
150
|
unsafe.push({ repoPath: f.repoPath, reason: safe.reason });
|
|
110
151
|
continue;
|
|
111
152
|
}
|
|
153
|
+
if (!includeIgnored && isIgnoredPath(f.repoPath)) {
|
|
154
|
+
ignoredRemote.push(f.repoPath);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
112
157
|
candidates.push({ ...f, target: safe.target, binary: !sync.isTextMime(f.mime_type) });
|
|
113
158
|
}
|
|
114
159
|
|
|
@@ -141,6 +186,9 @@ async function pull(args) {
|
|
|
141
186
|
` ${ui.c.dim('posiblemente iguales:')} ${maybeSame.length}\n` +
|
|
142
187
|
` ${ui.c.dim('sólo locales:')} ${onlyLocal.length} ${ui.c.dim('(el pull no los toca)')}` +
|
|
143
188
|
(binaryCount ? `\n ${ui.c.dim('binarios:')} ${binaryCount}` : '') +
|
|
189
|
+
(ignoredRemote.length
|
|
190
|
+
? `\n ${ui.c.dim('ignorados:')} ${ignoredRemote.length} ${ui.c.dim('(no se bajan)')}`
|
|
191
|
+
: '') +
|
|
144
192
|
(unsafe.length ? `\n ${ui.c.red('inseguros:')} ${unsafe.length}` : '')
|
|
145
193
|
);
|
|
146
194
|
ui.info('');
|
|
@@ -157,6 +205,9 @@ async function pull(args) {
|
|
|
157
205
|
);
|
|
158
206
|
}
|
|
159
207
|
for (const f of onlyLocal) ui.info(` ${ui.c.dim('·')} ${ui.c.dim(`${f.repoPath} (sólo local, se conserva)`)}`);
|
|
208
|
+
for (const p of ignoredRemote) {
|
|
209
|
+
ui.info(` ${ui.c.dim('·')} ${ui.c.dim(`${p} (ignorado localmente, no se baja)`)}`);
|
|
210
|
+
}
|
|
160
211
|
ui.info('');
|
|
161
212
|
if (conflicting.length > 0) {
|
|
162
213
|
ui.warn(`${conflicting.length} archivo(s) locales se sobrescribirían: hace falta --force.`);
|
|
@@ -204,9 +255,17 @@ async function pull(args) {
|
|
|
204
255
|
|
|
205
256
|
// 6) Descargar y escribir, con pool. Recién acá se sabe si un archivo del
|
|
206
257
|
// mismo tamaño era realmente idéntico: si lo era, no se reescribe.
|
|
258
|
+
// Antes de pisar un archivo que difería del remoto (sólo pasa con --force)
|
|
259
|
+
// se guarda la versión local bajo `.shilo/respaldo/<ISO>/`: recuperarse de
|
|
260
|
+
// una divergencia dejaba de depender de haber copiado a mano antes.
|
|
261
|
+
const conflictPaths = new Set(conflicting.map((f) => f.repoPath));
|
|
262
|
+
const backupStamp = new Date().toISOString();
|
|
263
|
+
const backupDir = backupDirFor(dir, backupStamp);
|
|
264
|
+
|
|
207
265
|
const written = [];
|
|
208
266
|
const unchanged = [];
|
|
209
267
|
const failed = [];
|
|
268
|
+
const backedUp = [];
|
|
210
269
|
const outcomes = await sync.runPool(toDownload, CONCURRENCY, async (f) => {
|
|
211
270
|
const creds = await mgr.valid();
|
|
212
271
|
const read = await client.readFile(projectId, f.repoPath, creds);
|
|
@@ -222,9 +281,13 @@ async function pull(args) {
|
|
|
222
281
|
return { repoPath: f.repoPath, skipped: true };
|
|
223
282
|
}
|
|
224
283
|
|
|
284
|
+
const saved = conflictPaths.has(f.repoPath)
|
|
285
|
+
? backupLocal(backupDir, f.target, f.repoPath)
|
|
286
|
+
: false;
|
|
287
|
+
|
|
225
288
|
fs.mkdirSync(path.dirname(f.target), { recursive: true });
|
|
226
289
|
fs.writeFileSync(f.target, bytes);
|
|
227
|
-
return { repoPath: f.repoPath, skipped: false };
|
|
290
|
+
return { repoPath: f.repoPath, skipped: false, backedUp: saved };
|
|
228
291
|
}
|
|
229
292
|
|
|
230
293
|
const content = read.file && read.file.content != null ? read.file.content : '';
|
|
@@ -234,9 +297,13 @@ async function pull(args) {
|
|
|
234
297
|
return { repoPath: f.repoPath, skipped: true };
|
|
235
298
|
}
|
|
236
299
|
|
|
300
|
+
const saved = conflictPaths.has(f.repoPath)
|
|
301
|
+
? backupLocal(backupDir, f.target, f.repoPath)
|
|
302
|
+
: false;
|
|
303
|
+
|
|
237
304
|
fs.mkdirSync(path.dirname(f.target), { recursive: true });
|
|
238
305
|
fs.writeFileSync(f.target, content, 'utf8');
|
|
239
|
-
return { repoPath: f.repoPath, skipped: false };
|
|
306
|
+
return { repoPath: f.repoPath, skipped: false, backedUp: saved };
|
|
240
307
|
});
|
|
241
308
|
|
|
242
309
|
for (const res of outcomes) {
|
|
@@ -247,6 +314,7 @@ async function pull(args) {
|
|
|
247
314
|
unchanged.push(res.repoPath);
|
|
248
315
|
} else {
|
|
249
316
|
written.push(res.repoPath);
|
|
317
|
+
if (res.backedUp) backedUp.push(res.repoPath);
|
|
250
318
|
ui.success(`${ui.c.green('↓')} ${res.repoPath}`);
|
|
251
319
|
}
|
|
252
320
|
}
|
|
@@ -271,6 +339,22 @@ async function pull(args) {
|
|
|
271
339
|
|
|
272
340
|
// 8) Resumen.
|
|
273
341
|
ui.info('');
|
|
342
|
+
if (backedUp.length > 0) {
|
|
343
|
+
ui.info(
|
|
344
|
+
`${backedUp.length} versión(es) local(es) sobrescrita(s) quedaron respaldadas en ` +
|
|
345
|
+
ui.c.cyan(path.relative(dir, backupDir).split(path.sep).join('/'))
|
|
346
|
+
);
|
|
347
|
+
ui.info(ui.c.dim('(está dentro de .shilo/, así que no se sube ni aparece en el status.)'));
|
|
348
|
+
ui.info('');
|
|
349
|
+
}
|
|
350
|
+
if (ignoredRemote.length > 0) {
|
|
351
|
+
ui.info(
|
|
352
|
+
ui.c.dim(
|
|
353
|
+
`${ignoredRemote.length} archivo(s) del remoto están ignorados localmente y no se bajaron ` +
|
|
354
|
+
`(--include-ignored para bajarlos igual).`
|
|
355
|
+
)
|
|
356
|
+
);
|
|
357
|
+
}
|
|
274
358
|
const tail = unchanged.length ? ` (${unchanged.length} ya estaban al día)` : '';
|
|
275
359
|
if (failed.length === 0) {
|
|
276
360
|
ui.success(`Pull completo: ${written.length} archivo(s) actualizados en "${dir}"${tail}.`);
|