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/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
  *
@@ -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}.`);