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 +25 -6
- package/dist/git/git.js +87 -37
- 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,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
|
-
*
|
|
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 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
|
-
|
|
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 (
|
|
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);
|
|
263
|
+
const name = b.slice(slash + 1);
|
|
266
264
|
if (!name || name.startsWith("HEAD"))
|
|
267
265
|
continue;
|
|
268
|
-
if (!
|
|
269
|
-
branches.push({ name, ref: b });
|
|
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
|
|
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
|
|
286
|
-
|
|
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
|
-
|
|
293
|
-
|
|
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
|
*
|
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