pull-request-split-advisor 3.2.6 → 3.2.8

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,24 @@ 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 (const sibling of siblings) {
403
+ ui.subsection(sibling.name);
404
+ if (sibling.commits.length === 0) {
405
+ ui.muted(" Sin commits detectados.");
406
+ }
407
+ else {
408
+ ui.table(["SHA", "Mensaje"], sibling.commits.map((c) => [c.sha, c.subject]), true);
409
+ }
410
+ }
411
+ ui.subsection(`${currentBranch} ← rama actual`);
412
+ if (currentBranchCommits.length === 0) {
413
+ ui.muted(" Sin commits detectados.");
414
+ }
415
+ else {
416
+ ui.table(["SHA", "Mensaje"], currentBranchCommits.map((c) => [c.sha, c.subject]), true);
417
+ }
399
418
  }
400
419
  // ─── Pipeline de análisis ────────────────────────────────────────────────
401
420
  ui.spinner.start("Analizando cambios del working tree...");
package/dist/git/git.js CHANGED
@@ -226,73 +226,123 @@ 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 ramas locales Y remotas para no perder ramas hermanas
247
- // que existen en el remoto pero aún no han sido descargadas localmente.
248
- // Para cada entrada guardamos { name, ref }:
249
- // - name: nombre de la rama sin prefijo de remoto (para mostrar al usuario y para checkout)
250
- // - ref: ref resolvible por git (local: igual al name; remota: "origin/feature/…")
248
+ function discoverSortedSiblings(currentBranch, baseBranch, familyPrefix) {
251
249
  const branches = [];
252
- // Ramas locales
250
+ const seenNames = new Set();
251
+ // 1. Ramas locales
253
252
  const localRaw = shSafe("git branch --format=%(refname:short)");
254
253
  for (const b of localRaw.split("\n").map((s) => s.trim()).filter(Boolean)) {
255
254
  branches.push({ name: b, ref: b });
255
+ seenNames.add(b.toUpperCase());
256
256
  }
257
- // Ramas remotas (ej. "origin/feature/FASTY-0001-xyz")
258
- // Se usa el nombre sin el prefijo del remoto (primer segmento hasta "/").
259
- // Si ya existe la rama localmente, se omite (la ref local tiene precedencia).
257
+ // 2. Ramas remotas ya cacheadas localmente (git branch -r)
260
258
  const remoteRaw = shSafe("git branch -r --format=%(refname:short)");
261
259
  for (const b of remoteRaw.split("\n").map((s) => s.trim()).filter(Boolean)) {
262
260
  const slash = b.indexOf("/");
263
261
  if (slash < 0)
264
262
  continue;
265
- const name = b.slice(slash + 1); // "origin/feature/…" → "feature/…"
263
+ const name = b.slice(slash + 1);
266
264
  if (!name || name.startsWith("HEAD"))
267
265
  continue;
268
- if (!branches.some((x) => x.name === name)) {
269
- branches.push({ name, ref: b }); // ref = "origin/feature/…" (resolvible sin checkout)
266
+ if (!seenNames.has(name.toUpperCase())) {
267
+ branches.push({ name, ref: b });
268
+ seenNames.add(name.toUpperCase());
269
+ }
270
+ }
271
+ // 3. git ls-remote — ramas que existen en el remoto pero nunca fueron descargadas
272
+ const remote = shSafe("git remote").split("\n")[0]?.trim() || "origin";
273
+ const lsRemoteRaw = shSafe(`git ls-remote --heads ${q(remote)} ${q(`refs/heads/${familyPrefix}*`)}`);
274
+ for (const line of lsRemoteRaw.split("\n").map((s) => s.trim()).filter(Boolean)) {
275
+ const tabIdx = line.indexOf("\t");
276
+ if (tabIdx < 0)
277
+ continue;
278
+ const ref = line.slice(tabIdx + 1);
279
+ const name = ref.replace(/^refs\/heads\//, "");
280
+ if (!name || seenNames.has(name.toUpperCase()))
281
+ continue;
282
+ const trackingRef = `refs/remotes/${remote}/${name}`;
283
+ const resolvedRef = shSafe(`git rev-parse --verify ${q(trackingRef)}`).trim()
284
+ ? `${remote}/${name}`
285
+ : ref;
286
+ if (resolvedRef === ref) {
287
+ shSafe(`git fetch ${q(remote)} ${q(`refs/heads/${name}:refs/remotes/${remote}/${name}`)} --no-tags`);
270
288
  }
289
+ branches.push({ name, ref: `${remote}/${name}` });
290
+ seenNames.add(name.toUpperCase());
271
291
  }
272
292
  const candidates = branches.filter(({ name }) => name !== currentBranch &&
273
293
  name.toUpperCase().startsWith(familyPrefix.toUpperCase()));
274
294
  if (candidates.length === 0)
275
- return null;
276
- // De los candidatos, quedarnos con los que tengan commits adelantados
277
- // respecto a la base y ordenarlos por committerdate descendente.
278
- // Si la rama solo existe en el remoto, la ref "origin/feature/…" es
279
- // resolvible directamente por git sin necesidad de checkout local.
295
+ return [];
280
296
  const withAhead = candidates.filter(({ ref }) => {
281
297
  const count = shSafe(`git rev-list ${q(baseBranch)}..${q(ref)} --count`);
282
298
  return (parseInt(count, 10) || 0) > 0;
283
299
  });
284
300
  if (withAhead.length === 0)
285
- return null;
286
- // El más reciente es el que tiene el commit más nuevo
287
- const sorted = withAhead.sort((a, b) => {
301
+ return [];
302
+ return withAhead.sort((a, b) => {
288
303
  const dateA = shSafe(`git log -1 --format=%ct ${q(a.ref)}`);
289
304
  const dateB = shSafe(`git log -1 --format=%ct ${q(b.ref)}`);
290
305
  return (parseInt(dateB, 10) || 0) - (parseInt(dateA, 10) || 0);
291
306
  });
292
- // Retornar el nombre sin prefijo de remoto para que el usuario pueda usar
293
- // "git checkout <name>" y git resuelva automáticamente la ref de tracking.
307
+ }
308
+ /**
309
+ * Busca la rama hermana más reciente de la misma "familia" con commits adelantados
310
+ * respecto a `baseBranch`, excluyendo `currentBranch`.
311
+ *
312
+ * @param currentBranch - Rama actual (se excluye de la búsqueda).
313
+ * @param baseBranch - Rama base del análisis.
314
+ * @returns Nombre de la rama hermana más reciente con commits adelantados, o `null`.
315
+ */
316
+ export function findCascadeParent(currentBranch, baseBranch) {
317
+ const familyMatch = currentBranch.match(/^(feature|fix|bugfix|hotfix|refactor|chore|docs|test|perf|ci)\/([A-Z]+-\d+)/i);
318
+ if (!familyMatch)
319
+ return null;
320
+ const familyPrefix = `${familyMatch[1]}/${familyMatch[2].toUpperCase()}`;
321
+ const sorted = discoverSortedSiblings(currentBranch, baseBranch, familyPrefix);
294
322
  return sorted[0]?.name ?? null;
295
323
  }
324
+ /**
325
+ * Devuelve todas las ramas hermanas de la cascada en orden cronológico
326
+ * (la más antigua primero) con sus commits respecto a `baseBranch`.
327
+ */
328
+ export function findCascadeSiblings(currentBranch, baseBranch) {
329
+ const familyMatch = currentBranch.match(/^(feature|fix|bugfix|hotfix|refactor|chore|docs|test|perf|ci)\/([A-Z]+-\d+)/i);
330
+ if (!familyMatch)
331
+ return [];
332
+ const familyPrefix = `${familyMatch[1]}/${familyMatch[2].toUpperCase()}`;
333
+ // reverse() porque discoverSortedSiblings devuelve la más reciente primero
334
+ const sorted = discoverSortedSiblings(currentBranch, baseBranch, familyPrefix).reverse();
335
+ return sorted.map(({ name, ref }) => ({
336
+ name,
337
+ commits: getCommitsAheadOf(ref, baseBranch),
338
+ }));
339
+ }
340
+ /**
341
+ * Devuelve los commits de HEAD adelantados respecto a `baseBranch`.
342
+ */
343
+ export function localAheadCommits(baseBranch) {
344
+ return getCommitsAheadOf("HEAD", baseBranch);
345
+ }
296
346
  /**
297
347
  * Hace push de una rama local al remoto indicado.
298
348
  *
@@ -505,6 +505,21 @@ 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) => {
513
+ const commitsHtml = branch.commits.length > 0
514
+ ? `<ul style="margin:4px 0 0 0;padding:0;list-style:none">${branch.commits.map((c) => `<li style="font-size:.82em;padding:2px 0">` +
515
+ `<code style="color:#60a5fa;margin-right:8px">${esc(c.sha)}</code>` +
516
+ `<span style="opacity:.9">${esc(c.subject)}</span></li>`).join("")}</ul>`
517
+ : `<div style="font-size:.8em;opacity:.5;margin-top:2px">Sin commits</div>`;
518
+ return `<div style="margin:6px 0;padding:8px 10px;background:rgba(0,0,0,.15);border-radius:5px;border-left:3px solid ${branch.isCurrent ? "#fbbf24" : "#94a3b8"}">
519
+ <div style="font-weight:600;font-size:.88em;color:${branch.isCurrent ? "#fbbf24" : "#94a3b8"}">${esc(branch.name)}${branch.isCurrent ? " <span style=\"font-size:.8em;opacity:.7\">← rama actual</span>" : ""}</div>
520
+ ${commitsHtml}
521
+ </div>`;
522
+ }).join("");
508
523
  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
524
  <div class="disclaimer-icon">⚠️</div>
510
525
  <div class="disclaimer-body">
@@ -519,6 +534,10 @@ export function renderHtmlReport(input) {
519
534
  <p>${parentNote}</p>
520
535
  <p><strong>Comandos recomendados:</strong></p>
521
536
  <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>
537
+ <div style="margin-top:14px">
538
+ <p style="margin:0 0 6px;font-weight:600;font-size:.9em">Estado de la cascada:</p>
539
+ ${cascadeStateHtml}
540
+ </div>
522
541
  <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
542
  </div>
524
543
  </section>`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pull-request-split-advisor",
3
- "version": "3.2.6",
3
+ "version": "3.2.8",
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",