shilo-cli 0.6.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 -243
- package/bin/shilo.js +197 -165
- package/package.json +1 -1
- package/src/ShiloClient.js +1027 -706
- 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 +105 -5
- package/src/commands/push.js +848 -394
- 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/remote.js +15 -3
- 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
|
*
|
|
@@ -55,7 +87,7 @@ async function pull(args) {
|
|
|
55
87
|
const ctx = resolveContext(args, { requireDir: false });
|
|
56
88
|
if (!ctx.ok) return ctx.code;
|
|
57
89
|
|
|
58
|
-
const { dir, projectId, baseURL, cfg, client, mgr } = ctx;
|
|
90
|
+
const { dir, projectId, baseURL, cfg, client, mgr, savedRemote } = ctx;
|
|
59
91
|
|
|
60
92
|
// La carpeta destino puede no existir todavía: la creamos al escribir. Si
|
|
61
93
|
// existe pero es un archivo, es un error del usuario.
|
|
@@ -68,6 +100,19 @@ async function pull(args) {
|
|
|
68
100
|
|
|
69
101
|
ui.info(`Proyecto ${ui.c.bold(projectId)} en ${ui.c.cyan(baseURL)} — recorriendo árbol ...`);
|
|
70
102
|
|
|
103
|
+
// La versión actual ANTES de recorrer el árbol: si alguien guarda en el
|
|
104
|
+
// medio, la base que anotamos queda más vieja que los archivos que bajamos,
|
|
105
|
+
// y eso sólo puede hacer que el push siguiente vea un conflicto de más, nunca
|
|
106
|
+
// uno de menos. Un servidor sin la Etapa 3 no la tiene; se sigue sin base.
|
|
107
|
+
let main = null;
|
|
108
|
+
try {
|
|
109
|
+
const creds = await mgr.valid();
|
|
110
|
+
const listed = await client.listProposals(projectId, creds, { state: 'abierta' });
|
|
111
|
+
main = listed.main;
|
|
112
|
+
} catch (e) {
|
|
113
|
+
if (!e.notSupported) ui.warn(`No se pudo leer la versión actual: ${e.message}`);
|
|
114
|
+
}
|
|
115
|
+
|
|
71
116
|
let tree;
|
|
72
117
|
try {
|
|
73
118
|
tree = await sync.walkRemote(client, projectId, mgr);
|
|
@@ -88,14 +133,27 @@ async function pull(args) {
|
|
|
88
133
|
// devuelve una URL firmada en vez de contenido, y se bajan como bytes. Lo
|
|
89
134
|
// que se saltaba antes no era una limitación del formato sino del camino:
|
|
90
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
|
+
|
|
91
144
|
const candidates = [];
|
|
92
145
|
const unsafe = [];
|
|
146
|
+
const ignoredRemote = [];
|
|
93
147
|
for (const f of tree.files) {
|
|
94
148
|
const safe = safeTarget(dir, f.repoPath);
|
|
95
149
|
if (!safe.ok) {
|
|
96
150
|
unsafe.push({ repoPath: f.repoPath, reason: safe.reason });
|
|
97
151
|
continue;
|
|
98
152
|
}
|
|
153
|
+
if (!includeIgnored && isIgnoredPath(f.repoPath)) {
|
|
154
|
+
ignoredRemote.push(f.repoPath);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
99
157
|
candidates.push({ ...f, target: safe.target, binary: !sync.isTextMime(f.mime_type) });
|
|
100
158
|
}
|
|
101
159
|
|
|
@@ -128,6 +186,9 @@ async function pull(args) {
|
|
|
128
186
|
` ${ui.c.dim('posiblemente iguales:')} ${maybeSame.length}\n` +
|
|
129
187
|
` ${ui.c.dim('sólo locales:')} ${onlyLocal.length} ${ui.c.dim('(el pull no los toca)')}` +
|
|
130
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
|
+
: '') +
|
|
131
192
|
(unsafe.length ? `\n ${ui.c.red('inseguros:')} ${unsafe.length}` : '')
|
|
132
193
|
);
|
|
133
194
|
ui.info('');
|
|
@@ -144,6 +205,9 @@ async function pull(args) {
|
|
|
144
205
|
);
|
|
145
206
|
}
|
|
146
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
|
+
}
|
|
147
211
|
ui.info('');
|
|
148
212
|
if (conflicting.length > 0) {
|
|
149
213
|
ui.warn(`${conflicting.length} archivo(s) locales se sobrescribirían: hace falta --force.`);
|
|
@@ -191,9 +255,17 @@ async function pull(args) {
|
|
|
191
255
|
|
|
192
256
|
// 6) Descargar y escribir, con pool. Recién acá se sabe si un archivo del
|
|
193
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
|
+
|
|
194
265
|
const written = [];
|
|
195
266
|
const unchanged = [];
|
|
196
267
|
const failed = [];
|
|
268
|
+
const backedUp = [];
|
|
197
269
|
const outcomes = await sync.runPool(toDownload, CONCURRENCY, async (f) => {
|
|
198
270
|
const creds = await mgr.valid();
|
|
199
271
|
const read = await client.readFile(projectId, f.repoPath, creds);
|
|
@@ -209,9 +281,13 @@ async function pull(args) {
|
|
|
209
281
|
return { repoPath: f.repoPath, skipped: true };
|
|
210
282
|
}
|
|
211
283
|
|
|
284
|
+
const saved = conflictPaths.has(f.repoPath)
|
|
285
|
+
? backupLocal(backupDir, f.target, f.repoPath)
|
|
286
|
+
: false;
|
|
287
|
+
|
|
212
288
|
fs.mkdirSync(path.dirname(f.target), { recursive: true });
|
|
213
289
|
fs.writeFileSync(f.target, bytes);
|
|
214
|
-
return { repoPath: f.repoPath, skipped: false };
|
|
290
|
+
return { repoPath: f.repoPath, skipped: false, backedUp: saved };
|
|
215
291
|
}
|
|
216
292
|
|
|
217
293
|
const content = read.file && read.file.content != null ? read.file.content : '';
|
|
@@ -221,9 +297,13 @@ async function pull(args) {
|
|
|
221
297
|
return { repoPath: f.repoPath, skipped: true };
|
|
222
298
|
}
|
|
223
299
|
|
|
300
|
+
const saved = conflictPaths.has(f.repoPath)
|
|
301
|
+
? backupLocal(backupDir, f.target, f.repoPath)
|
|
302
|
+
: false;
|
|
303
|
+
|
|
224
304
|
fs.mkdirSync(path.dirname(f.target), { recursive: true });
|
|
225
305
|
fs.writeFileSync(f.target, content, 'utf8');
|
|
226
|
-
return { repoPath: f.repoPath, skipped: false };
|
|
306
|
+
return { repoPath: f.repoPath, skipped: false, backedUp: saved };
|
|
227
307
|
});
|
|
228
308
|
|
|
229
309
|
for (const res of outcomes) {
|
|
@@ -234,6 +314,7 @@ async function pull(args) {
|
|
|
234
314
|
unchanged.push(res.repoPath);
|
|
235
315
|
} else {
|
|
236
316
|
written.push(res.repoPath);
|
|
317
|
+
if (res.backedUp) backedUp.push(res.repoPath);
|
|
237
318
|
ui.success(`${ui.c.green('↓')} ${res.repoPath}`);
|
|
238
319
|
}
|
|
239
320
|
}
|
|
@@ -245,9 +326,12 @@ async function pull(args) {
|
|
|
245
326
|
const complete = failed.length === 0 && (args.force || conflicting.length === 0);
|
|
246
327
|
try {
|
|
247
328
|
if (complete) {
|
|
248
|
-
remote.write(dir, { projectId, baseURL, lastSync: new Date().toISOString() });
|
|
329
|
+
remote.write(dir, { projectId, baseURL, lastSync: new Date().toISOString(), base: main });
|
|
249
330
|
} else if (written.length > 0) {
|
|
250
|
-
|
|
331
|
+
// Carpeta a medias: se conserva la base anterior. Lo que sí se bajó ya
|
|
332
|
+
// coincide con la versión actual, así que en el próximo push no cuenta
|
|
333
|
+
// como cambio propio.
|
|
334
|
+
remote.write(dir, { projectId, baseURL, base: (savedRemote && savedRemote.base) || null });
|
|
251
335
|
}
|
|
252
336
|
} catch (e) {
|
|
253
337
|
ui.warn(`No se pudo escribir ${remote.REMOTE_FILENAME}: ${e.message}`);
|
|
@@ -255,6 +339,22 @@ async function pull(args) {
|
|
|
255
339
|
|
|
256
340
|
// 8) Resumen.
|
|
257
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
|
+
}
|
|
258
358
|
const tail = unchanged.length ? ` (${unchanged.length} ya estaban al día)` : '';
|
|
259
359
|
if (failed.length === 0) {
|
|
260
360
|
ui.success(`Pull completo: ${written.length} archivo(s) actualizados en "${dir}"${tail}.`);
|