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 +26 -6
- package/dist/git/git.js +63 -45
- package/dist/output/report.js +21 -0
- package/package.json +1 -1
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,
|
|
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
|
-
//
|
|
364
|
-
//
|
|
365
|
-
|
|
366
|
-
const cascadeParent =
|
|
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
|
-
*
|
|
231
|
-
*
|
|
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
|
-
|
|
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.
|
|
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);
|
|
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;
|
|
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
|
|
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
|
|
319
|
-
|
|
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
|
-
|
|
326
|
-
|
|
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
|
*
|
package/dist/output/report.js
CHANGED
|
@@ -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