pull-request-split-advisor 3.2.7 → 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 +25 -6
- package/dist/git/git.js +61 -44
- package/dist/output/report.js +19 -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,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,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,32 +268,22 @@ 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}` });
|
|
@@ -307,25 +292,57 @@ export function findCascadeParent(currentBranch, baseBranch) {
|
|
|
307
292
|
const candidates = branches.filter(({ name }) => name !== currentBranch &&
|
|
308
293
|
name.toUpperCase().startsWith(familyPrefix.toUpperCase()));
|
|
309
294
|
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.
|
|
295
|
+
return [];
|
|
313
296
|
const withAhead = candidates.filter(({ ref }) => {
|
|
314
297
|
const count = shSafe(`git rev-list ${q(baseBranch)}..${q(ref)} --count`);
|
|
315
298
|
return (parseInt(count, 10) || 0) > 0;
|
|
316
299
|
});
|
|
317
300
|
if (withAhead.length === 0)
|
|
318
|
-
return
|
|
319
|
-
|
|
320
|
-
const sorted = withAhead.sort((a, b) => {
|
|
301
|
+
return [];
|
|
302
|
+
return withAhead.sort((a, b) => {
|
|
321
303
|
const dateA = shSafe(`git log -1 --format=%ct ${q(a.ref)}`);
|
|
322
304
|
const dateB = shSafe(`git log -1 --format=%ct ${q(b.ref)}`);
|
|
323
305
|
return (parseInt(dateB, 10) || 0) - (parseInt(dateA, 10) || 0);
|
|
324
306
|
});
|
|
325
|
-
|
|
326
|
-
|
|
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);
|
|
327
322
|
return sorted[0]?.name ?? null;
|
|
328
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
|
+
}
|
|
329
346
|
/**
|
|
330
347
|
* Hace push de una rama local al remoto indicado.
|
|
331
348
|
*
|
package/dist/output/report.js
CHANGED
|
@@ -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