pull-request-split-advisor 3.2.7 → 3.2.9

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/dist/cli.js CHANGED
@@ -28,7 +28,7 @@ import { parseAndValidateWorkingBranch } from "./git/branch-naming.js";
28
28
  import { loadConfig } from "./config/config.js";
29
29
  import { APP_BIN, APP_NAME, APP_VERSION } from "./shared/constants.js";
30
30
  import { applyPlan } from "./git/executor.js";
31
- import { detectRemote, fetchQuiet, findCascadeParent, getCurrentBranch, localAheadCount, localBehindCount, remoteTrackingExists, requireCleanIndex, requireGitRepo } from "./git/git.js";
31
+ import { detectRemote, fetchQuiet, findCascadeSiblings, localAheadCommits, getCurrentBranch, localAheadCount, localBehindCount, remoteTrackingExists, requireCleanIndex, requireGitRepo } from "./git/git.js";
32
32
  import { buildBlocks, buildDependencyEdges, computeFileOrigins, findBestPlan, gatherChangedFiles, getFileStats } from "./core/planner.js";
33
33
  import { resetTrackedFilesCache } from "./shared/utils.js";
34
34
  import { exportJson, writeHtmlReport, writeScoreHtmlReport } from "./output/report.js";
@@ -360,13 +360,14 @@ async function main() {
360
360
  let cascadeWarning;
361
361
  if (aheadCount > 0) {
362
362
  branchHasCommits = true;
363
- // Buscar la última rama hermana de la cascada (mismo tipo/equipo-número)
364
- // que tenga commits adelantados. Si existe, el usuario debe continuar
365
- // la cascada desde esa rama; si no, debe partir desde la base.
366
- const cascadeParent = findCascadeParent(currentBranch, baseBranch);
363
+ // Descubrir todas las ramas hermanas de la cascada (local + remoto + ls-remote)
364
+ // y los commits que la rama actual ya tiene adelantados.
365
+ const siblings = findCascadeSiblings(currentBranch, baseBranch);
366
+ const cascadeParent = siblings.length > 0 ? (siblings[siblings.length - 1]?.name ?? null) : null;
367
367
  const branchFrom = cascadeParent ?? baseBranch;
368
+ const currentBranchCommits = localAheadCommits(baseBranch);
368
369
  const suggestedBranch = currentBranch.replace(/^(feature|fix|bugfix|hotfix|refactor|chore|docs|test|perf|ci)\/(.+?)(-wip|-backup-.+)?$/, (_, type, rest) => `${type}/${rest}-wip`);
369
- cascadeWarning = { aheadCount, cascadeParent, branchFrom, suggestedBranch };
370
+ cascadeWarning = { aheadCount, cascadeParent, branchFrom, suggestedBranch, siblings, currentBranchCommits };
370
371
  const cascadeLines = cascadeParent
371
372
  ? [
372
373
  `Se detectó la rama '${cascadeParent}' como parte de la misma`,
@@ -396,6 +397,25 @@ async function main() {
396
397
  "El análisis continúa de forma informativa.",
397
398
  "La opción --apply está deshabilitada en esta ejecución."
398
399
  ], "red");
400
+ // ─── Estado de la cascada en terminal ───────────────────────────────────
401
+ ui.section("ESTADO DE LA CASCADA", "#");
402
+ for (let i = 0; i < siblings.length; i++) {
403
+ const sibling = siblings[i];
404
+ ui.subsection(`#${i + 1} ${sibling.name}`);
405
+ if (sibling.commits.length === 0) {
406
+ ui.muted(" Sin commits detectados.");
407
+ }
408
+ else {
409
+ ui.table(["SHA", "Mensaje"], sibling.commits.map((c) => [c.sha, c.subject]), true);
410
+ }
411
+ }
412
+ ui.subsection(`#${siblings.length + 1} ${currentBranch} ← rama actual`);
413
+ if (currentBranchCommits.length === 0) {
414
+ ui.muted(" Sin commits detectados.");
415
+ }
416
+ else {
417
+ ui.table(["SHA", "Mensaje"], currentBranchCommits.map((c) => [c.sha, c.subject]), true);
418
+ }
399
419
  }
400
420
  // ─── Pipeline de análisis ────────────────────────────────────────────────
401
421
  ui.spinner.start("Analizando cambios del working tree...");
package/dist/git/git.js CHANGED
@@ -226,31 +226,26 @@ export function localAheadCount(baseBranch) {
226
226
  const count = shSafe(`git rev-list ${q(baseBranch)}..HEAD --count`);
227
227
  return parseInt(count, 10) || 0;
228
228
  }
229
+ /** Devuelve los commits de `branchRef` que no están en `baseBranch`. */
230
+ function getCommitsAheadOf(branchRef, baseBranch) {
231
+ const raw = shSafe(`git log ${q(baseBranch)}..${q(branchRef)} --pretty=format:%h%n%s`);
232
+ if (!raw.trim())
233
+ return [];
234
+ const lines = raw.split("\n");
235
+ const out = [];
236
+ for (let i = 0; i + 1 < lines.length; i += 2) {
237
+ const sha = lines[i].trim();
238
+ const subject = lines[i + 1]?.trim() ?? "";
239
+ if (sha)
240
+ out.push({ sha, subject });
241
+ }
242
+ return out;
243
+ }
229
244
  /**
230
- * Busca la última rama local de la misma "familia" (mismo prefijo tipo/equipo-número)
231
- * que tiene commits adelantados respecto a `baseBranch`, excluyendo `currentBranch`.
232
- *
233
- * Útil para sugerir al usuario que cree la siguiente rama de la cascada desde
234
- * la punta de la rama hermana más reciente en lugar de desde la rama base.
235
- *
236
- * @param currentBranch - Rama actual (se excluye de la búsqueda).
237
- * @param baseBranch - Rama base del análisis.
238
- * @returns Nombre de la rama hermana más reciente con commits adelantados, o `null`.
245
+ * Descubre ramas hermanas de la cascada (sin `currentBranch`), ordenadas
246
+ * por committerdate descendente (la más reciente primero).
239
247
  */
240
- export function findCascadeParent(currentBranch, baseBranch) {
241
- // Extraer el prefijo familia: tipo/EQUIPO-NUMERO
242
- const familyMatch = currentBranch.match(/^(feature|fix|bugfix|hotfix|refactor|chore|docs|test|perf|ci)\/([A-Z]+-\d+)/i);
243
- if (!familyMatch)
244
- return null;
245
- const familyPrefix = `${familyMatch[1]}/${familyMatch[2].toUpperCase()}`;
246
- // Construir lista de candidatos desde tres fuentes (en orden de precedencia):
247
- // 1. Ramas locales
248
- // 2. Refs de tracking remoto ya descargadas (git branch -r)
249
- // 3. Ramas que existen en el remoto pero nunca fueron descargadas (git ls-remote)
250
- //
251
- // Para cada entrada guardamos { name, ref }:
252
- // - name: nombre sin prefijo de remoto (para mostrar al usuario y hacer checkout)
253
- // - ref: ref resolvible por git (local o "origin/branch")
248
+ function discoverSortedSiblings(currentBranch, baseBranch, familyPrefix) {
254
249
  const branches = [];
255
250
  const seenNames = new Set();
256
251
  // 1. Ramas locales
@@ -273,59 +268,82 @@ export function findCascadeParent(currentBranch, baseBranch) {
273
268
  seenNames.add(name.toUpperCase());
274
269
  }
275
270
  }
276
- // 3. Consultar el remoto directamente con git ls-remote para descubrir
277
- // ramas que existen en el servidor pero nunca fueron descargadas.
278
- // Filtramos por el prefijo familia para que sea rápido incluso con
279
- // repositorios que tienen cientos de ramas.
271
+ // 3. git ls-remote — ramas que existen en el remoto pero nunca fueron descargadas
280
272
  const remote = shSafe("git remote").split("\n")[0]?.trim() || "origin";
281
273
  const lsRemoteRaw = shSafe(`git ls-remote --heads ${q(remote)} ${q(`refs/heads/${familyPrefix}*`)}`);
282
274
  for (const line of lsRemoteRaw.split("\n").map((s) => s.trim()).filter(Boolean)) {
283
- // Formato: "<sha>\trefs/heads/<name>"
284
275
  const tabIdx = line.indexOf("\t");
285
276
  if (tabIdx < 0)
286
277
  continue;
287
- const ref = line.slice(tabIdx + 1); // "refs/heads/feature/…"
278
+ const ref = line.slice(tabIdx + 1);
288
279
  const name = ref.replace(/^refs\/heads\//, "");
289
280
  if (!name || seenNames.has(name.toUpperCase()))
290
281
  continue;
291
- // Usar "<remote>/<name>" como ref resolvible si el tracking existe,
292
- // o la ref completa "refs/remotes/<remote>/<name>" como fallback.
293
- // Para git rev-list y git log podemos usar directamente la ref completa.
294
282
  const trackingRef = `refs/remotes/${remote}/${name}`;
295
283
  const resolvedRef = shSafe(`git rev-parse --verify ${q(trackingRef)}`).trim()
296
284
  ? `${remote}/${name}`
297
- : ref; // "refs/heads/<name>" solo funciona si fue fetched; caer en ls-remote sha
298
- // Si no hay tracking local, no podemos resolver commits sin fetch.
299
- // En ese caso hacemos un fetch puntual de esa única rama antes de usarla.
285
+ : ref;
300
286
  if (resolvedRef === ref) {
301
- // fetch puntual y silencioso de solo esta rama para obtener su historial
302
287
  shSafe(`git fetch ${q(remote)} ${q(`refs/heads/${name}:refs/remotes/${remote}/${name}`)} --no-tags`);
303
288
  }
304
289
  branches.push({ name, ref: `${remote}/${name}` });
305
290
  seenNames.add(name.toUpperCase());
306
291
  }
307
292
  const candidates = branches.filter(({ name }) => name !== currentBranch &&
308
- name.toUpperCase().startsWith(familyPrefix.toUpperCase()));
293
+ name.toUpperCase().startsWith(familyPrefix.toUpperCase()) &&
294
+ !/backup/i.test(name));
309
295
  if (candidates.length === 0)
310
- return null;
311
- // De los candidatos, quedarnos con los que tengan commits adelantados
312
- // respecto a la base y ordenarlos por committerdate descendente.
296
+ return [];
313
297
  const withAhead = candidates.filter(({ ref }) => {
314
298
  const count = shSafe(`git rev-list ${q(baseBranch)}..${q(ref)} --count`);
315
299
  return (parseInt(count, 10) || 0) > 0;
316
300
  });
317
301
  if (withAhead.length === 0)
318
- return null;
319
- // El más reciente es el que tiene el commit más nuevo
320
- const sorted = withAhead.sort((a, b) => {
302
+ return [];
303
+ return withAhead.sort((a, b) => {
321
304
  const dateA = shSafe(`git log -1 --format=%ct ${q(a.ref)}`);
322
305
  const dateB = shSafe(`git log -1 --format=%ct ${q(b.ref)}`);
323
306
  return (parseInt(dateB, 10) || 0) - (parseInt(dateA, 10) || 0);
324
307
  });
325
- // Retornar el nombre sin prefijo de remoto para que el usuario pueda usar
326
- // "git checkout <name>" y git resuelva automáticamente la ref de tracking.
308
+ }
309
+ /**
310
+ * Busca la rama hermana más reciente de la misma "familia" con commits adelantados
311
+ * respecto a `baseBranch`, excluyendo `currentBranch`.
312
+ *
313
+ * @param currentBranch - Rama actual (se excluye de la búsqueda).
314
+ * @param baseBranch - Rama base del análisis.
315
+ * @returns Nombre de la rama hermana más reciente con commits adelantados, o `null`.
316
+ */
317
+ export function findCascadeParent(currentBranch, baseBranch) {
318
+ const familyMatch = currentBranch.match(/^(feature|fix|bugfix|hotfix|refactor|chore|docs|test|perf|ci)\/([A-Z]+-\d+)/i);
319
+ if (!familyMatch)
320
+ return null;
321
+ const familyPrefix = `${familyMatch[1]}/${familyMatch[2].toUpperCase()}`;
322
+ const sorted = discoverSortedSiblings(currentBranch, baseBranch, familyPrefix);
327
323
  return sorted[0]?.name ?? null;
328
324
  }
325
+ /**
326
+ * Devuelve todas las ramas hermanas de la cascada en orden cronológico
327
+ * (la más antigua primero) con sus commits respecto a `baseBranch`.
328
+ */
329
+ export function findCascadeSiblings(currentBranch, baseBranch) {
330
+ const familyMatch = currentBranch.match(/^(feature|fix|bugfix|hotfix|refactor|chore|docs|test|perf|ci)\/([A-Z]+-\d+)/i);
331
+ if (!familyMatch)
332
+ return [];
333
+ const familyPrefix = `${familyMatch[1]}/${familyMatch[2].toUpperCase()}`;
334
+ // reverse() porque discoverSortedSiblings devuelve la más reciente primero
335
+ const sorted = discoverSortedSiblings(currentBranch, baseBranch, familyPrefix).reverse();
336
+ return sorted.map(({ name, ref }) => ({
337
+ name,
338
+ commits: getCommitsAheadOf(ref, baseBranch),
339
+ }));
340
+ }
341
+ /**
342
+ * Devuelve los commits de HEAD adelantados respecto a `baseBranch`.
343
+ */
344
+ export function localAheadCommits(baseBranch) {
345
+ return getCommitsAheadOf("HEAD", baseBranch);
346
+ }
329
347
  /**
330
348
  * Hace push de una rama local al remoto indicado.
331
349
  *
@@ -505,6 +505,23 @@ export function renderHtmlReport(input) {
505
505
  ? `Se detectó la rama <code>${esc(cw.cascadeParent)}</code> como parte de la misma cascada.
506
506
  Para mantener el orden correcto de PRs apilados, la nueva rama debe crearse desde esa rama.`
507
507
  : `No se encontraron otras ramas de la misma cascada. Crea la nueva rama directamente desde la base.`;
508
+ const allBranches = [
509
+ ...cw.siblings,
510
+ { name: currentBranch, commits: cw.currentBranchCommits, isCurrent: true }
511
+ ];
512
+ const cascadeStateHtml = allBranches.map((branch, idx) => {
513
+ const num = idx + 1;
514
+ const color = branch.isCurrent ? "#e2e8f0" : "#94a3b8";
515
+ const commitsHtml = branch.commits.length > 0
516
+ ? `<ul style="margin:4px 0 0 0;padding:0;list-style:none">${branch.commits.map((c) => `<li style="font-size:.82em;padding:2px 0">` +
517
+ `<code style="color:#60a5fa;margin-right:8px">${esc(c.sha)}</code>` +
518
+ `<span style="opacity:.9">${esc(c.subject)}</span></li>`).join("")}</ul>`
519
+ : `<div style="font-size:.8em;opacity:.5;margin-top:2px">Sin commits</div>`;
520
+ return `<div style="margin:6px 0;padding:8px 10px;background:rgba(0,0,0,.15);border-radius:5px;border-left:3px solid ${color}">
521
+ <div style="font-weight:600;font-size:.88em;color:${color}"><span style="opacity:.55;margin-right:6px">#${num}</span>${esc(branch.name)}${branch.isCurrent ? " <span style=\"font-size:.8em;opacity:.7\">← rama actual</span>" : ""}</div>
522
+ ${commitsHtml}
523
+ </div>`;
524
+ }).join("");
508
525
  return `<section class="disclaimer-banner" style="background:rgba(239,68,68,.12);border-color:#f87171" role="alert" aria-label="Aviso de integridad de cascada">
509
526
  <div class="disclaimer-icon">⚠️</div>
510
527
  <div class="disclaimer-body">
@@ -519,6 +536,10 @@ export function renderHtmlReport(input) {
519
536
  <p>${parentNote}</p>
520
537
  <p><strong>Comandos recomendados:</strong></p>
521
538
  <pre style="background:rgba(0,0,0,.25);padding:10px 14px;border-radius:6px;font-size:.85em;margin:8px 0 0"># Crear la siguiente rama de la cascada\ngit checkout ${esc(cw.branchFrom)}\ngit checkout -b ${esc(cw.suggestedBranch)}</pre>
539
+ <div style="margin-top:14px">
540
+ <p style="margin:0 0 6px;font-weight:600;font-size:.9em">Estado de la cascada:</p>
541
+ ${cascadeStateHtml}
542
+ </div>
522
543
  <p class="disclaimer-footer" style="margin-top:8px">Este reporte es informativo. El apply quedó deshabilitado en esta ejecución para preservar la integridad del plan en cascada.</p>
523
544
  </div>
524
545
  </section>`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pull-request-split-advisor",
3
- "version": "3.2.7",
3
+ "version": "3.2.9",
4
4
  "description": "CLI that analyses your Git working tree and suggests how to split changes into smaller, reviewable pull requests and commits.",
5
5
  "keywords": [
6
6
  "git",