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/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
- out[key] = value;
58
+ set(key, value);
41
59
  } else if (boolSet.has(key)) {
42
- out[key] = true;
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
- out[key] = next;
65
+ set(key, next);
48
66
  i++;
49
67
  } else {
50
- out[key] = true;
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
- out[key] = true;
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
- out[key] = next;
82
+ set(key, next);
65
83
  i++;
66
84
  } else {
67
- out[key] = true;
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
 
@@ -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
- const total = cmp.onlyLocal.length + cmp.onlyRemote.length + cmp.differing.length;
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 cmp.onlyRemote) ui.info(` ${ui.c.red('-')} ${item.repoPath} ${ui.c.dim('(sólo remoto)')}`);
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;
@@ -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)
@@ -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
- remote.write(dir, { projectId, baseURL });
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}.`);