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.
@@ -36,6 +36,11 @@ const PROPOSAL_MAX_BYTES = 4 * 1024 * 1024; // el server corta en 5 MB; margen
36
36
  const HEAD_BYTES = 8192;
37
37
 
38
38
  // Cuántos ejemplos se muestran por cada motivo de salteo.
39
+ // Techo por campo del bloque `agent` y del prompt (Etapa 4, puntos 1 y 2). El
40
+ // del prompt es el mismo que aplica el servidor: avisar acá ahorra el viaje.
41
+ const AGENT_FIELD_MAX = 200;
42
+ const PROMPT_MAX_BYTES = 64 * 1024;
43
+
39
44
  const SKIP_EXAMPLES = 5;
40
45
  // A partir de cuántos salteos en una misma carpeta de primer nivel sugerimos
41
46
  // ignorarla entera.
@@ -149,6 +154,61 @@ function preflight(dir) {
149
154
  return { uploads, skipped: skips, ignored, gitDirs, findings };
150
155
  }
151
156
 
157
+ /**
158
+ * Aplica los selectores de `--only` sobre los archivos que dejó el preflight.
159
+ *
160
+ * Cada selector se normaliza con `sync.toRepoPath`, que ya blinda contra salir
161
+ * de la carpeta, y matchea por igualdad exacta o como prefijo de carpeta
162
+ * (`Documentacion/` alcanza todo lo de adentro).
163
+ *
164
+ * Un selector que no matchea nada es ERROR, no un push silencioso de todo:
165
+ * confundir "no subí nada de lo que pedí" con "subí el repo entero" es
166
+ * exactamente el fallo que arruinaría la confianza en el flag.
167
+ *
168
+ * Devuelve `{ uploads, leftOut }` o `{ error }`.
169
+ */
170
+ function selectUploads(dir, allUploads, only) {
171
+ const selectors = (Array.isArray(only) ? only : only ? [only] : [])
172
+ .map((s) => String(s).trim())
173
+ .filter(Boolean);
174
+
175
+ if (selectors.length === 0) return { uploads: allUploads, leftOut: [] };
176
+
177
+ const normalized = [];
178
+ for (const raw of selectors) {
179
+ const repoPath = sync.toRepoPath(dir, raw);
180
+ if (!repoPath) return { error: `--only "${raw}" queda fuera de la carpeta del push.` };
181
+ normalized.push({ raw, repoPath });
182
+ }
183
+
184
+ const matched = new Set();
185
+ const uploads = [];
186
+ const leftOut = [];
187
+
188
+ for (const u of allUploads) {
189
+ const hit = normalized.find(
190
+ (s) => u.relPath === s.repoPath || u.relPath.startsWith(`${s.repoPath}/`)
191
+ );
192
+ if (hit) {
193
+ matched.add(hit.repoPath);
194
+ uploads.push(u);
195
+ } else {
196
+ leftOut.push(u);
197
+ }
198
+ }
199
+
200
+ const misses = normalized.filter((s) => !matched.has(s.repoPath));
201
+ if (misses.length > 0) {
202
+ return {
203
+ error:
204
+ `--only no encontró nada para: ${misses.map((s) => `"${s.raw}"`).join(', ')}. ` +
205
+ `Puede estar ignorado, salteado, o escrito distinto; nada se subió.`,
206
+ };
207
+ }
208
+
209
+ return { uploads, leftOut };
210
+ }
211
+
152
212
  /**
153
213
  * Reporte agrupado de salteos. Un repo con un `venv/` adentro generaba miles de
154
214
  * líneas "saltado …" que tapaban todo lo demás; acá se resume por motivo, con
@@ -292,8 +352,26 @@ async function push(args) {
292
352
  return 1;
293
353
  }
294
354
 
355
+ // Procedencia (Etapa 4, punto 1). Sin --agent no se manda bloque alguno y el
356
+ // guardado queda como escrito por una persona, que es la verdad.
357
+ const agent = buildAgent(args);
358
+ if (agent && agent.error) {
359
+ ui.error(agent.error);
360
+ return 1;
361
+ }
362
+
363
+ const promptRead = readPrompt(args, agent);
364
+ if (promptRead.error) {
365
+ ui.error(promptRead.error);
366
+ return 1;
367
+ }
368
+ for (const w of promptRead.warnings) ui.warn(w);
369
+ const promptText = promptRead.prompt;
370
+
295
371
  // 3) PREFLIGHT: todo offline. Recién después de esto se toca la red.
296
- const { uploads, skipped, ignored, gitDirs, findings } = preflight(dir);
372
+ // `allUploads` es la carpeta entera; `--only` recorta DESPUÉS (ver 3b),
373
+ // para que los ignores y el escáner de secretos corran igual siempre.
374
+ const { uploads: allUploads, skipped, ignored, gitDirs, findings } = preflight(dir);
297
375
 
298
376
  if (gitDirs.length > 0) {
299
377
  ui.warn(
@@ -320,12 +398,23 @@ async function push(args) {
320
398
  );
321
399
  }
322
400
 
323
- if (uploads.length === 0) {
401
+ if (allUploads.length === 0) {
324
402
  ui.info('No hay archivos para subir.');
325
403
  ui.info(ui.c.dim(`(${ignored} ignorados, ${skipped.length} salteados)`));
326
404
  return 0;
327
405
  }
328
406
 
407
+ // 3b) `--only`: recorta QUÉ se sube, no qué se mira. El push nunca borra
408
+ // archivos remotos (su plan es added/modified/unchanged/failed), así que
409
+ // subir un subconjunto es seguro por construcción: no hay semántica nueva.
410
+ const selection = selectUploads(dir, allUploads, args.only);
411
+ if (selection.error) {
412
+ ui.error(selection.error);
413
+ return 1;
414
+ }
415
+ const uploads = selection.uploads;
416
+ const leftOut = selection.leftOut;
417
+
329
418
  // 4) Cliente + control de ritmo. `onThrottle` baja la concurrencia sola si el
330
419
  // servidor empieza a responder 429, en vez de insistir hasta que corte.
331
420
  const limit = sync.resolveConcurrency(args);
@@ -533,8 +622,9 @@ async function push(args) {
533
622
  for (const f of plan.failed) ui.error(`${f.relPath} — ${f.reason}`);
534
623
  return 1;
535
624
  }
536
- if (useIndex && !dryRun) saveIndex(dir, index, plan, uploads);
625
+ if (useIndex && !dryRun) saveIndex(dir, index, plan, allUploads);
537
626
  ui.success('Todo al día. Nada para subir.');
627
+ reportLeftOut(leftOut, remoteByPath);
538
628
  return 0;
539
629
  }
540
630
 
@@ -543,6 +633,7 @@ async function push(args) {
543
633
  for (const u of plan.modified) ui.info(` ${ui.c.yellow('~')} ${u.relPath}`);
544
634
  for (const f of plan.failed) ui.info(` ${ui.c.red('!')} ${f.relPath} — ${f.reason}`);
545
635
  ui.info('');
636
+ reportLeftOut(leftOut, remoteByPath);
546
637
  ui.info(ui.c.dim('(--dry-run: no se subió nada)'));
547
638
  return plan.failed.length ? 1 : 0;
548
639
  }
@@ -714,7 +805,12 @@ async function push(args) {
714
805
  const title = buildMessage(message, changes.length, toSend.length, bi + 1, batches.length, card);
715
806
  let result;
716
807
  try {
717
- result = await client.propose(projectId, { base, title, summary: '', changes }, await mgr.valid());
808
+ result = await client.propose(
809
+ projectId,
810
+ { base, title, summary: '', changes, agent, prompt: promptText, card },
811
+ await mgr.valid()
812
+ );
813
+ for (const w of result.warnings || []) ui.warn(w);
718
814
  config.persistCredsIfChanged(cfg, mgr.current);
719
815
  } catch (e) {
720
816
  // Una base que el servidor no conoce (proyecto recreado, historial
@@ -1131,7 +1227,7 @@ async function push(args) {
1131
1227
  );
1132
1228
  try {
1133
1229
  const creds = await mgr.valid();
1134
- await client.createCommit(projectId, msg, batchChanges, creds);
1230
+ await client.createCommit(projectId, msg, batchChanges, creds, { card });
1135
1231
  committed += batchChanges.length;
1136
1232
  commitCount += 1;
1137
1233
  config.persistCredsIfChanged(cfg, mgr.current);
@@ -1175,7 +1271,7 @@ async function push(args) {
1175
1271
  for (const u of uploaded) {
1176
1272
  pushindex.record(index, u, pushedAt);
1177
1273
  }
1178
- saveIndex(dir, index, plan, uploads, pushedAt);
1274
+ saveIndex(dir, index, plan, allUploads, pushedAt);
1179
1275
  }
1180
1276
 
1181
1277
  // 11) Anotar la sincronización y la base nueva. Sólo si TODO quedó en la
@@ -1189,6 +1285,7 @@ async function push(args) {
1189
1285
 
1190
1286
  // 12) Resumen final.
1191
1287
  ui.info('');
1288
+ reportLeftOut(leftOut, remoteByPath);
1192
1289
  for (const o of open) reportOpen(o);
1193
1290
  if (stale > 0) {
1194
1291
  ui.warn(
@@ -1291,26 +1388,154 @@ async function loadContent(u) {
1291
1388
  return u.content;
1292
1389
  }
1293
1390
 
1391
+ /**
1392
+ * Lo que `--only` dejó afuera. "Una pieza = una tarjeta" sólo funciona si la
1393
+ * CLI te recuerda lo que quedó atrás.
1394
+ *
1395
+ * "Cambiado" se decide contra el ÁRBOL REMOTO —falta allá, o está con otro
1396
+ * tamaño—, que es el mismo criterio de `shilo status` y no cuesta un request
1397
+ * extra: el árbol ya se recorrió para planificar. El índice local no sirve acá:
1398
+ * `pull` no lo escribe, así que en una carpeta que se bajó de nuevo queda viejo
1399
+ * y daría por cambiado medio repo.
1400
+ *
1401
+ * Misma concesión que `status`: dos archivos del mismo tamaño se dan por
1402
+ * iguales aunque difieran byte a byte.
1403
+ */
1404
+ function reportLeftOut(leftOut, remoteByPath) {
1405
+ if (!leftOut || leftOut.length === 0) return;
1406
+
1407
+ const changed = leftOut.filter((u) => {
1408
+ const r = remoteByPath.get(u.relPath);
1409
+ if (!r) return true; // nunca subido
1410
+ return typeof r.size === 'number' && r.size !== u.size;
1411
+ });
1412
+ if (changed.length === 0) {
1413
+ ui.info(
1414
+ ui.c.dim(
1415
+ `${leftOut.length} archivo(s) quedaron fuera de este push (--only); ninguno tiene cambios sin subir.`
1416
+ )
1417
+ );
1418
+ return;
1419
+ }
1420
+
1421
+ ui.warn(`${changed.length} archivo(s) con cambios locales quedaron fuera de este push (--only):`);
1422
+ for (const u of changed.slice(0, SKIP_EXAMPLES)) ui.info(` ${ui.c.dim(u.relPath)}`);
1423
+ if (changed.length > SKIP_EXAMPLES) {
1424
+ ui.info(ui.c.dim(` … y ${changed.length - SKIP_EXAMPLES} más`));
1425
+ }
1426
+ }
1427
+
1294
1428
  /**
1295
1429
  * Registra en el índice los archivos que ya estaban idénticos (para no volver a
1296
1430
  * compararlos), saca los que ya no existen y lo escribe.
1431
+ *
1432
+ * `presentUploads` es lo que EXISTE en la carpeta, no lo que se subió en esta
1433
+ * corrida: con `--only` son cosas distintas. Podar con la selección borraría del
1434
+ * índice las entradas del resto del repo, y el push completo siguiente tendría
1435
+ * que recompararlo todo — lento, no incorrecto, pero evitable. Las entradas
1436
+ * nuevas se fusionan sobre el índice ya leído de disco, no lo reemplazan.
1297
1437
  */
1298
- function saveIndex(dir, index, plan, uploads, pushedAt = Date.now()) {
1438
+ function saveIndex(dir, index, plan, presentUploads, pushedAt = Date.now()) {
1299
1439
  for (const u of plan.unchanged) {
1300
1440
  if (u.content != null) {
1301
1441
  pushindex.record(index, { ...u, hash: pushindex.hashContent(u.content) }, pushedAt);
1302
1442
  }
1303
1443
  }
1304
- pushindex.prune(index, new Set(uploads.map((u) => u.relPath)));
1444
+ pushindex.prune(index, new Set(presentUploads.map((u) => u.relPath)));
1305
1445
  pushindex.write(dir, index);
1306
1446
  }
1307
1447
 
1448
+ /**
1449
+ * El bloque `agent` que se declara al proponer, o null si nadie lo declaró.
1450
+ *
1451
+ * `--model` y `--session` sin `--agent` son un error y no un descuido que se
1452
+ * pueda completar solo: decir qué modelo escribió algo sin decir qué
1453
+ * herramienta lo corrió deja una procedencia que no se puede rastrear hasta
1454
+ * ningún lado.
1455
+ */
1456
+ function buildAgent(args) {
1457
+ const name = typeof args.agent === 'string' && args.agent.trim() ? args.agent.trim() : null;
1458
+ const model = typeof args.model === 'string' && args.model.trim() ? args.model.trim() : null;
1459
+ const session = typeof args.session === 'string' && args.session.trim() ? args.session.trim() : null;
1460
+
1461
+ if (!name) {
1462
+ if (model || session) {
1463
+ return { error: 'Para declarar procedencia hace falta --agent <nombre>; --model y --session solos no alcanzan.' };
1464
+ }
1465
+ return null;
1466
+ }
1467
+
1468
+ return {
1469
+ name: name.slice(0, AGENT_FIELD_MAX),
1470
+ ...(model ? { model: model.slice(0, AGENT_FIELD_MAX) } : {}),
1471
+ ...(session ? { session_id: session.slice(0, AGENT_FIELD_MAX) } : {}),
1472
+ tool: 'push',
1473
+ };
1474
+ }
1475
+
1476
+ /**
1477
+ * El prompt que se sube, si quien llama lo pidió explícitamente.
1478
+ *
1479
+ * No se infiere del entorno ni se lee de ningún archivo por convención: un
1480
+ * prompt puede llevar adentro cualquier cosa que alguien haya pegado en su
1481
+ * terminal, y subir eso sin que nadie lo haya decidido es lo mismo que
1482
+ * filtrarlo.
1483
+ *
1484
+ * Un prompt más largo que el techo se descarta con aviso, no se trunca: medio
1485
+ * prompt es peor que ninguno porque parece completo.
1486
+ */
1487
+ function readPrompt(args, agent) {
1488
+ const file = typeof args['prompt-file'] === 'string' ? args['prompt-file'].trim() : '';
1489
+ const inline = typeof args.prompt === 'string' ? args.prompt : '';
1490
+
1491
+ if (!file && !inline) return { prompt: null, warnings: [] };
1492
+ if (file && inline) {
1493
+ return { error: 'Elegí uno: --prompt o --prompt-file, no los dos.' };
1494
+ }
1495
+ if (!agent || agent.error) {
1496
+ return { error: 'El prompt se guarda dentro de la procedencia del agente: agregá --agent <nombre>.' };
1497
+ }
1498
+
1499
+ let text = inline;
1500
+ if (file) {
1501
+ const resolved = path.resolve(file);
1502
+ if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) {
1503
+ return { error: `No encontramos el archivo del prompt: ${resolved}` };
1504
+ }
1505
+ try {
1506
+ text = fs.readFileSync(resolved, 'utf8');
1507
+ } catch (e) {
1508
+ return { error: `No se pudo leer el prompt: ${e.message}` };
1509
+ }
1510
+ }
1511
+
1512
+ if (!text.trim()) return { prompt: null, warnings: [] };
1513
+
1514
+ const bytes = Buffer.byteLength(text, 'utf8');
1515
+ if (bytes > PROMPT_MAX_BYTES) {
1516
+ return {
1517
+ prompt: null,
1518
+ warnings: [
1519
+ `El prompt pesa ${Math.round(bytes / 1024)} KB y el techo es ` +
1520
+ `${PROMPT_MAX_BYTES / 1024} KB: se guarda el cambio sin él.`,
1521
+ ],
1522
+ };
1523
+ }
1524
+
1525
+ return { prompt: text, warnings: [] };
1526
+ }
1527
+
1308
1528
  /**
1309
1529
  * Arma el mensaje del commit. Si vino `--card`, se agrega como trailer
1310
1530
  * `[card: <id>]` al final: queda visible en el historial y es fácil de grepear
1311
1531
  * para cruzar un cambio con la tarjeta que lo pidió (Metrónomo u otro tablero).
1312
1532
  * El backend corta en 500 caracteres, así que recortamos el mensaje del usuario
1313
1533
  * antes que el trailer.
1534
+ *
1535
+ * Desde la Etapa 4 la tarjeta viaja ADEMÁS aparte, en `provenance.card`, que es
1536
+ * lo que se puede consultar (`GET /api/commits/<id>?card=…`). El trailer no se
1537
+ * saca ni se va a sacar: es el formato de todo lo ya guardado, y la procedencia
1538
+ * entra en el oid del commit, así que lo viejo no se puede completar.
1314
1539
  */
1315
1540
  function buildMessage(userMsg, batchCount, total, partNo, nBatches, card) {
1316
1541
  let base = userMsg || `shilo push: ${total} archivo(s)`;
@@ -0,0 +1,101 @@
1
+ 'use strict';
2
+
3
+ const config = require('../config');
4
+ const ui = require('../ui');
5
+ const sync = require('../sync');
6
+ const { resolveContext } = require('../context');
7
+
8
+ /**
9
+ * shilo review [versión] [--aprobado | --reparos | --rechazado] [-m <nota>] [--json]
10
+ *
11
+ * Revisión humana (Etapa 4). Sin veredicto, lista quién revisó esa versión y
12
+ * qué dijo. Con uno, deja constancia. La versión es `actual` si no se dice
13
+ * otra cosa, o lo que imprime `shilo log` (alcanza un prefijo de 7 caracteres).
14
+ *
15
+ * El positional es una versión, no una carpeta: el contexto se resuelve siempre
16
+ * sobre el directorio actual, igual que en `log`.
17
+ */
18
+ const FLAGS = { aprobado: 'aprobado', reparos: 'con_reparos', rechazado: 'rechazado' };
19
+
20
+ const ETIQUETAS = {
21
+ aprobado: 'aprobado',
22
+ con_reparos: 'con reparos',
23
+ rechazado: 'rechazado',
24
+ };
25
+
26
+ async function review(args) {
27
+ const version = typeof args._[0] === 'string' && args._[0].trim() ? args._[0].trim() : 'actual';
28
+ const elegidos = Object.keys(FLAGS).filter((f) => args[f]);
29
+ if (elegidos.length > 1) {
30
+ ui.error('Elegí un solo veredicto: --aprobado, --reparos o --rechazado.');
31
+ return 1;
32
+ }
33
+ const verdict = elegidos.length ? FLAGS[elegidos[0]] : null;
34
+ const note = typeof args.message === 'string' ? args.message.trim() : '';
35
+
36
+ if (!verdict && note) {
37
+ ui.error('La nota va con un veredicto: --aprobado, --reparos o --rechazado.');
38
+ return 1;
39
+ }
40
+ // La 017 lo exige igual; avisarlo acá ahorra un viaje y dice por qué.
41
+ if (verdict && verdict !== 'aprobado' && !note) {
42
+ ui.error('Un reparo o un rechazo necesita una nota que diga qué falta: -m "<nota>".');
43
+ return 1;
44
+ }
45
+
46
+ const ctx = resolveContext({ ...args, _: [] });
47
+ if (!ctx.ok) return ctx.code;
48
+ const { projectId, cfg, client, mgr } = ctx;
49
+
50
+ let result;
51
+ try {
52
+ const creds = await mgr.valid();
53
+ result = verdict
54
+ ? await client.review(projectId, { version, verdict, note }, creds)
55
+ : await client.listReviews(projectId, version, creds);
56
+ } catch (e) {
57
+ ui.error(e.message);
58
+ return 1;
59
+ } finally {
60
+ config.persistCredsIfChanged(cfg, mgr.current);
61
+ }
62
+
63
+ if (args.json) {
64
+ console.log(JSON.stringify({ projectId, version: result.version, reviews: result.reviews }, null, 2));
65
+ return 0;
66
+ }
67
+
68
+ const corto = result.version ? String(result.version).slice(0, 8) : version;
69
+ if (verdict) ui.success(`Revisión registrada: ${ETIQUETAS[verdict]} (${corto}).`);
70
+
71
+ ui.info(`Revisiones de ${ui.c.yellow(corto)}`);
72
+ ui.info('');
73
+ if (result.reviews.length === 0) {
74
+ ui.info('Nadie la revisó todavía.');
75
+ ui.info(ui.c.dim('Para revisarla: shilo review ' + (version === 'actual' ? '' : version + ' ') + '--aprobado'));
76
+ return 0;
77
+ }
78
+ for (const r of result.reviews) {
79
+ ui.info(`${pintar(r.verdict)} ${ui.c.bold(sync.describeAuthor(r.reviewer))} ${ui.c.dim(sync.formatDate(r.reviewed_at))}`);
80
+ if (r.note) ui.info(` ${r.note}`);
81
+ }
82
+ return 0;
83
+ }
84
+
85
+ function pintar(verdict) {
86
+ const texto = ETIQUETAS[verdict] || verdict;
87
+ if (verdict === 'aprobado') return ui.c.green(texto);
88
+ if (verdict === 'rechazado') return ui.c.red(texto);
89
+ return ui.c.yellow(texto);
90
+ }
91
+
92
+ /** Una marca corta para `shilo log`: la revisión más reciente manda. */
93
+ function marcaDeRevision(reviews) {
94
+ if (!Array.isArray(reviews) || reviews.length === 0) return '';
95
+ const ultima = reviews[0];
96
+ const extra = reviews.length > 1 ? ` +${reviews.length - 1}` : '';
97
+ return `${pintar(ultima.verdict)} por ${sync.describeAuthor(ultima.reviewer)}${extra}`;
98
+ }
99
+
100
+ module.exports = review;
101
+ module.exports.marcaDeRevision = marcaDeRevision;
@@ -57,10 +57,24 @@ async function status(args) {
57
57
 
58
58
  // 3) "Detrás del remoto" = lo que otro subió y yo no tengo igual: archivos
59
59
  // que faltan localmente, o que difieren y cambiaron después de mi lastSync.
60
+ //
61
+ // Lo que está ignorado localmente NO cuenta como "atrás": `walkLocal` no
62
+ // lo ve, así que quedaría en `onlyRemote` para siempre y el exit 2 —que
63
+ // significa "pull antes de trabajar"— se volvería ruido permanente. Se
64
+ // informa aparte: que alguien haya subido tu `settings.local.json` importa,
65
+ // pero no es trabajo que te falte bajar.
66
+ const isIgnoredPath = sync.makePathMatcher(dir);
67
+
60
68
  const behind = [];
69
+ const ignoredRemote = [];
61
70
  for (const item of cmp.onlyRemote) {
71
+ if (isIgnoredPath(item.repoPath)) {
72
+ ignoredRemote.push({ ...item, kind: 'ignorado local' });
73
+ continue;
74
+ }
62
75
  behind.push({ ...item, kind: 'falta local' });
63
76
  }
77
+ ignoredRemote.sort((a, b) => a.repoPath.localeCompare(b.repoPath));
64
78
  for (const item of cmp.differing) {
65
79
  if (sync.changedSinceSync(item.remote, lastSync) !== false) {
66
80
  behind.push({ ...item, kind: 'difiere' });
@@ -97,6 +111,9 @@ async function status(args) {
97
111
  localFiles: local.files.length,
98
112
  behind: behind.map((i) => ({ path: i.repoPath, kind: i.kind, lastCommit: decorate(i) })),
99
113
  ahead: ahead.map((i) => ({ path: i.repoPath, kind: i.kind })),
114
+ // Campo aparte a propósito: si fueran parte de `behind`, un script
115
+ // volvería a leer "estás desactualizado" por algo que nunca vas a bajar.
116
+ ignoredRemote: ignoredRemote.map((i) => ({ path: i.repoPath })),
100
117
  unchanged: cmp.same.length,
101
118
  listErrors: tree.errors,
102
119
  },
@@ -125,8 +142,25 @@ async function status(args) {
125
142
  }
126
143
  ui.info('');
127
144
 
145
+ /** Los ignorados que existen en el remoto: una línea, o la lista con --show-ignored. */
146
+ const reportIgnored = () => {
147
+ if (ignoredRemote.length === 0) return;
148
+ ui.info(
149
+ ui.c.dim(
150
+ `${ignoredRemote.length} archivo(s) existen en el remoto pero están ignorados localmente` +
151
+ (args['show-ignored'] ? ':' : ' (--show-ignored para verlos).')
152
+ )
153
+ );
154
+ if (args['show-ignored']) {
155
+ for (const item of ignoredRemote) ui.info(` ${ui.c.dim('·')} ${ui.c.dim(item.repoPath)}`);
156
+ }
157
+ ui.info('');
158
+ };
159
+
128
160
  if (behind.length === 0 && ahead.length === 0) {
129
161
  ui.success(`Al día: ${cmp.same.length} archivo(s) coinciden con el remoto.`);
162
+ ui.info('');
163
+ reportIgnored();
130
164
  return 0;
131
165
  }
132
166
 
@@ -156,6 +190,8 @@ async function status(args) {
156
190
  ui.info('');
157
191
  }
158
192
 
193
+ reportIgnored();
194
+
159
195
  ui.info(
160
196
  ui.c.dim(
161
197
  ` ${cmp.same.length} sin cambios (por tamaño) ` +