@dansyuqri/spark-joy 0.1.5 → 0.2.0
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/README.md +26 -2
- package/dist/app/assets/index-CX9CpO88.js +8 -0
- package/dist/app/assets/index-ChCdW9F8.css +1 -0
- package/dist/app/index.html +2 -2
- package/dist/cli.js +326 -28
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
- package/plugin/skills/spark-joy/SKILL.md +41 -2
- package/dist/app/assets/index-BRFjS_MK.css +0 -1
- package/dist/app/assets/index-XTDapI2X.js +0 -8
|
@@ -0,0 +1 @@
|
|
|
1
|
+
:root{--bg:#fbfaf8;--panel:#fff;--ink:#1d1d1f;--muted:#6b6b72;--line:#e4e2dd;--accent:#c2410c;--accent-ink:#fff;--add-bg:#e9f8ec;--add-ink:#116329;--del-bg:#fdeceb;--del-ink:#a40e26;--hunk-bg:#f1f0fb;--note-bg:#fff8e5;--done:#15803d;--partial:#b45309;--todo:#9ca3af;--mono:ui-monospace, "SF Mono", Menlo, Consolas, monospace;--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light dark}@media (prefers-color-scheme:dark){:root{--lightningcss-light: ;--lightningcss-dark:initial;--bg:#151518;--panel:#1c1c20;--ink:#ececef;--muted:#9a9aa3;--line:#2e2e34;--accent:#fb923c;--accent-ink:#1a1a1a;--add-bg:#12301c;--add-ink:#7ee2a0;--del-bg:#3a1619;--del-ink:#ff9aa5;--hunk-bg:#23233a;--note-bg:#2f2a18;--done:#4ade80;--partial:#fbbf24;--todo:#6b7280}}*{box-sizing:border-box}body{background:var(--bg);color:var(--ink);margin:0;font:14px/1.5 system-ui,-apple-system,Segoe UI,sans-serif}a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}code,kbd{font-family:var(--mono);font-size:.92em}kbd{border:1px solid var(--line);border-radius:4px;padding:0 4px}button{font:inherit;border:1px solid var(--line);background:var(--panel);color:var(--ink);cursor:pointer;border-radius:6px;padding:6px 12px}button:hover:not(:disabled){border-color:var(--muted)}button:disabled{opacity:.4;cursor:default}button.primary{background:var(--accent);border-color:var(--accent);color:var(--accent-ink);font-weight:600}textarea{font:inherit;border:1px solid var(--line);background:var(--panel);width:100%;min-height:70px;color:var(--ink);resize:vertical;border-radius:6px;padding:8px}.muted{color:var(--muted)}.small{font-size:12px}.warn{color:var(--partial)}.pre{white-space:pre-wrap}.empty{max-width:640px;margin:80px auto;padding:0 16px}.topbar{z-index:10;background:var(--panel);border-bottom:1px solid var(--line);align-items:center;gap:20px;padding:10px 20px;display:flex;position:sticky;top:0}.brand{white-space:nowrap;font-weight:700}.spark{color:var(--accent)}.run{white-space:nowrap;text-overflow:ellipsis;flex:1;min-width:0;overflow:hidden}.tabs{gap:4px;display:flex}.tabs a{color:var(--muted);border-radius:6px;padding:4px 10px}.tabs a.on{background:var(--hunk-bg);color:var(--ink);font-weight:600}.progress{font-variant-numeric:tabular-nums;align-items:center;gap:8px;display:flex}.progress .bar{background:var(--line);border-radius:3px;width:120px;height:6px;overflow:hidden}.progress .bar div{background:var(--done);height:100%;transition:width .2s}.saving{width:70px;color:var(--muted);font-size:12px}.saving.error{color:var(--del-ink)}.status{vertical-align:-2px;justify-content:center;align-self:center;width:1.2em;display:inline-flex}.status.done{color:var(--done)}.status.partial{color:var(--partial)}.status.todo{color:var(--todo)}.badge{border:1px solid var(--line);color:var(--muted);white-space:nowrap;border-radius:10px;margin-left:6px;padding:0 6px;font-size:11px;display:inline-block}.risk{vertical-align:middle;border-radius:50%;width:8px;height:8px;margin-left:8px;display:inline-block}.risk-high{background:var(--del-ink)}.risk-med{background:var(--partial)}.issues{border:1px solid var(--line);background:var(--panel);border-radius:8px;margin:12px 0;padding:8px 12px}.issues.has-errors{border-color:var(--del-ink)}.issues li.error{color:var(--del-ink)}.issues li.warning{color:var(--partial)}.page{max-width:1200px;padding:24px 32px 80px}.overview{max-width:800px;font-size:15px}.strategies{grid-template-columns:repeat(auto-fit,minmax(340px,1fr));gap:16px;display:grid}.strategy{background:var(--panel);border:1px solid var(--line);border-radius:10px;flex-direction:column;padding:16px;display:flex}.strategy.on{border-color:var(--accent);box-shadow:0 0 0 1px var(--accent)}.strategy h3{margin:0 0 4px}.strategy .stats{color:var(--muted);font-size:12px}.chunk-list{flex:1;padding-left:0;list-style:none}.chunk-list .n{min-width:1.6em;color:var(--muted);font-variant-numeric:tabular-nums}.chunk-list li{align-items:center;gap:4px;padding:3px 0;display:flex}.chunk-list li .title{flex:1}.chunk-list .branch{color:var(--muted);font-size:11px;display:block}.strategy .hint{color:var(--muted);margin:10px 0 0;font-size:12px}.size{font-variant-numeric:tabular-nums;color:var(--muted);margin-left:6px;font-size:12px}.features li{margin-bottom:6px}.review{grid-template-columns:300px 1fr;align-items:start;display:grid}.sidebar{border-right:1px solid var(--line);max-height:calc(100vh - 53px);padding:16px 8px 16px 16px;position:sticky;top:53px;overflow:auto}.sidebar ol{margin:8px 0;padding:0;list-style:none}.sidebar li a{color:var(--ink);border-radius:6px;align-items:baseline;gap:6px;padding:5px 8px;display:flex}.sidebar li a:hover{background:var(--hunk-bg);text-decoration:none}.sidebar li.on a{background:var(--hunk-bg);font-weight:600}.sidebar li.skim{border-top:1px dashed var(--line);margin-top:8px;padding-top:8px}.sidebar .n{color:var(--muted);font-variant-numeric:tabular-nums;min-width:1.4em}.sidebar .title{flex:1}.chunk-head h1{margin:0 0 2px;font-size:20px}.chunk-head .intent{max-width:820px;margin:6px 0 0;font-size:15px}.focus{max-width:820px;color:var(--muted);margin:6px 0 0;padding-left:18px}.diff{margin-top:16px}.file{background:var(--panel);border:1px solid var(--line);border-radius:8px;margin-bottom:14px;overflow:hidden}.file>summary{cursor:pointer;border-bottom:1px solid var(--line);align-items:center;gap:4px;padding:8px 12px;display:flex}.file:not([open])>summary{border-bottom:none}.file .path{font-family:var(--mono);font-weight:600}.file .counts{font-family:var(--mono);margin-left:auto;font-size:12px}.add{color:var(--add-ink)}.del{color:var(--del-ink)}.file table{border-collapse:collapse;width:100%;font-family:var(--mono);table-layout:fixed;font-size:12.5px}.file col.no-col{width:52px}.file td.no{text-align:right;color:var(--muted);-webkit-user-select:none;user-select:none;cursor:pointer;vertical-align:top;padding:0 8px}.file td.no:hover{color:var(--accent)}.file td.code{white-space:pre-wrap;word-break:break-all;padding:0 8px}.sign{width:1.2em;color:var(--muted);-webkit-user-select:none;user-select:none;display:inline-block}tr.add td{background:var(--add-bg)}tr.del td{background:var(--del-bg)}tr.meta td{color:var(--muted);font-style:italic}tr.hunk-header td{background:var(--hunk-bg);color:var(--muted);padding:3px 8px}.hid{color:var(--ink);font-weight:700}tr.placeholder td{color:var(--muted);padding:8px;font-family:system-ui,sans-serif}tr.note-row td{background:var(--bg);padding:6px 12px 6px 112px}.line-note{background:var(--note-bg);border:1px solid var(--line);border-radius:6px;padding:8px 10px;font-family:system-ui,sans-serif;font-size:13px}.line-note .text{white-space:pre-wrap;cursor:text}.line-note .actions{gap:6px;margin-top:6px;display:flex}.line-note .actions button{padding:2px 8px;font-size:12px}.chunk-foot{background:var(--bg);border-top:1px solid var(--line);align-items:flex-end;gap:8px;padding:10px 0;display:flex;position:sticky;bottom:0}.chunk-foot textarea{flex:1;height:34px;min-height:34px;padding:6px 8px;transition:height .1s}.chunk-foot textarea:focus,.chunk-foot textarea:not(:placeholder-shown){height:80px}.totals{margin:0 0 8px;font-size:18px}.chunk-refs a{font-variant-numeric:tabular-nums;margin-left:8px;font-size:12px}.features{padding:0;list-style:none}.note-group{background:var(--panel);border:1px solid var(--line);border-radius:8px;margin-bottom:10px;padding:10px 16px}.note-group p{margin:6px 0}.export textarea{min-height:300px;font-family:var(--mono);font-size:12px}@media (width<=900px){.review{grid-template-columns:1fr}.sidebar{border-right:none;border-bottom:1px solid var(--line);max-height:none;position:static}.page{padding:16px}.run{display:none}}.file{scroll-margin-top:64px}.map-page{flex-direction:column;height:calc(100vh - 53px);display:flex}.crumbs{color:var(--muted);padding:10px 20px;font-size:13px}.map-canvas{cursor:grab;touch-action:none;-webkit-user-select:none;user-select:none;flex:1;position:relative;overflow:hidden}.map-canvas:active{cursor:grabbing}.empty-map{padding:20px}.zoom-in{transform-box:fill-box;animation:.22s ease-out zoom-in}@keyframes zoom-in{0%{opacity:0}}.node{cursor:pointer}.node rect{fill:var(--panel);stroke:var(--line);stroke-width:1.2px}.node:hover rect{stroke:var(--accent)}.node.neighbour rect{fill:var(--bg);stroke-dasharray:4 3}.node.neighbour text{opacity:.7}.node text{fill:var(--ink);font-size:13px}.node .title{font-weight:600}.node.file .title{font-family:var(--mono);font-size:12.5px}.node .n{fill:var(--muted);font-weight:400}.node .meta{fill:var(--muted);font-size:11px}.node .status.done{color:var(--done)}.node .status.partial{color:var(--partial)}.node .status.todo{color:var(--todo)}.node .risk-high{fill:var(--del-ink)}.node .risk-med{fill:var(--partial)}.edge path{fill:none;stroke:var(--muted);opacity:.7;transition:opacity .15s}.edge.link path{stroke-dasharray:5 4}.edge.faded{opacity:.15}.arrowhead{fill:var(--muted)}.edge-label rect{fill:var(--bg);stroke:var(--line)}.edge-label text{fill:var(--ink);font-size:11px}
|
package/dist/app/index.html
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<title>spark-joy review</title>
|
|
7
7
|
<link rel="icon" href="./favicon.svg" type="image/svg+xml" />
|
|
8
|
-
<script type="module" crossorigin src="/assets/index-
|
|
9
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
8
|
+
<script type="module" crossorigin src="/assets/index-CX9CpO88.js"></script>
|
|
9
|
+
<link rel="stylesheet" crossorigin href="/assets/index-ChCdW9F8.css">
|
|
10
10
|
</head>
|
|
11
11
|
<body>
|
|
12
12
|
<div id="root"></div>
|
package/dist/cli.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// cli/index.ts
|
|
2
|
-
import { copyFile, mkdir as mkdir3, readFile as
|
|
3
|
-
import { homedir } from "os";
|
|
4
|
-
import { join as
|
|
2
|
+
import { copyFile, mkdir as mkdir3, mkdtemp, readFile as readFile4, rm as rm2, writeFile as writeFile3 } from "fs/promises";
|
|
3
|
+
import { homedir, tmpdir } from "os";
|
|
4
|
+
import { join as join4, relative } from "path";
|
|
5
5
|
import { fileURLToPath } from "url";
|
|
6
6
|
import { parseArgs } from "util";
|
|
7
7
|
|
|
@@ -44,6 +44,9 @@ var Meta = z.object({
|
|
|
44
44
|
mergeBase: z.string(),
|
|
45
45
|
headSha: z.string(),
|
|
46
46
|
working: z.boolean(),
|
|
47
|
+
// What the diff covers. `mergeBase` is the commit it starts from: the
|
|
48
|
+
// merge-base for "pr" and "working", HEAD for "staged" and "uncommitted".
|
|
49
|
+
source: z.enum(["pr", "working", "staged", "uncommitted"]).optional(),
|
|
47
50
|
createdAt: z.string(),
|
|
48
51
|
stats: z.object({
|
|
49
52
|
files: z.number().int(),
|
|
@@ -62,7 +65,9 @@ var Chunk = z.object({
|
|
|
62
65
|
hunks: z.array(z.string()),
|
|
63
66
|
dependsOn: z.array(z.string()).default([]),
|
|
64
67
|
reviewFocus: z.array(z.string()).default([]),
|
|
65
|
-
risk: Risk.default("low")
|
|
68
|
+
risk: Risk.default("low"),
|
|
69
|
+
// Split mode: the branch this chunk becomes.
|
|
70
|
+
branch: z.string().min(1).optional()
|
|
66
71
|
});
|
|
67
72
|
var Strategy = z.object({
|
|
68
73
|
id: z.string().min(1),
|
|
@@ -86,6 +91,8 @@ var PlanLink = z.object({
|
|
|
86
91
|
label: z.string().min(1)
|
|
87
92
|
});
|
|
88
93
|
var Plan = z.object({
|
|
94
|
+
// "split" turns the plan into stacked PRs for the author instead of a review.
|
|
95
|
+
mode: z.enum(["review", "split"]).default("review"),
|
|
89
96
|
title: z.string().optional(),
|
|
90
97
|
overview: z.string().optional(),
|
|
91
98
|
strategies: z.array(Strategy).min(1),
|
|
@@ -116,6 +123,10 @@ var emptyState = () => ReviewState.parse({});
|
|
|
116
123
|
// shared/derive.ts
|
|
117
124
|
var isIgnorable = (h) => h.flags.some((f) => IGNORABLE_FLAGS.includes(f));
|
|
118
125
|
var changedLines = (hs) => hs.reduce((n, h) => n + h.added + h.removed, 0);
|
|
126
|
+
var headLabel = (meta) => meta.source === "staged" ? "index" : meta.working ? "working tree" : `HEAD ${meta.headSha.slice(0, 8)}`;
|
|
127
|
+
var branchSlug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40).replace(/-+$/, "") || "pr";
|
|
128
|
+
var defaultPrefix = (branch) => branch === "HEAD" ? "split" : `${branch}-split`;
|
|
129
|
+
var branchName = (prefix, n, c) => c.branch ?? `${prefix}/${n}-${branchSlug(c.title)}`;
|
|
119
130
|
var chunkNoteKey = (strategyId, chunkId) => `${strategyId}/${chunkId}`;
|
|
120
131
|
function strategyView(plan, hunks, strategyId) {
|
|
121
132
|
const strategy = plan.strategies.find((s) => s.id === strategyId) ?? plan.strategies[0];
|
|
@@ -185,6 +196,7 @@ var PADDING = [
|
|
|
185
196
|
[/^(introduces|adds support for|implements)\b/i, "drop the opener"],
|
|
186
197
|
[/\b(various|several|some|basically|simply|just|really|very)\b/i, "drop the filler word"]
|
|
187
198
|
];
|
|
199
|
+
var validBranch = (b) => !/[\s~^:?*[\\]|\.\.|@\{|\/\/|^[/.-]|[/.]$|\.lock$|\/\./.test(b) && b !== "@";
|
|
188
200
|
var words = (s) => s.trim().split(/\s+/).filter(Boolean).length;
|
|
189
201
|
function validatePlan(raw, hunks, budget = CHUNK_LINE_BUDGET) {
|
|
190
202
|
const parsed = Plan.safeParse(raw);
|
|
@@ -225,7 +237,12 @@ function validatePlan(raw, hunks, budget = CHUNK_LINE_BUDGET) {
|
|
|
225
237
|
warn("skim", `${describe(h)} is not flagged mechanical; make sure it really only needs a skim`);
|
|
226
238
|
}
|
|
227
239
|
}
|
|
228
|
-
|
|
240
|
+
const split = plan.mode === "split";
|
|
241
|
+
if (split) {
|
|
242
|
+
if (plan.strategies.length !== 1 || plan.strategies[0].id !== "split")
|
|
243
|
+
error("strategies", 'split mode takes exactly one strategy, with id "split"');
|
|
244
|
+
if (plan.skim.length) error("skim", "split mode has no skim; put every hunk in a PR");
|
|
245
|
+
} else if (plan.strategies.length < 2)
|
|
229
246
|
warn("strategies", "only one strategy; the reviewer is meant to choose between a foundations-first and a by-feature order");
|
|
230
247
|
const strategyIds = /* @__PURE__ */ new Set();
|
|
231
248
|
for (const s of plan.strategies) {
|
|
@@ -234,8 +251,14 @@ function validatePlan(raw, hunks, budget = CHUNK_LINE_BUDGET) {
|
|
|
234
251
|
strategyIds.add(s.id);
|
|
235
252
|
const seen = /* @__PURE__ */ new Map();
|
|
236
253
|
const chunkIndex = /* @__PURE__ */ new Map();
|
|
254
|
+
const branches = /* @__PURE__ */ new Set();
|
|
237
255
|
s.chunks.forEach((c, i) => {
|
|
238
256
|
const cw = `${where} / chunk ${c.id}`;
|
|
257
|
+
if (c.branch !== void 0) {
|
|
258
|
+
if (!validBranch(c.branch)) error(cw, `invalid branch name "${c.branch}"`);
|
|
259
|
+
else if (branches.has(c.branch)) error(cw, `branch ${c.branch} is used twice`);
|
|
260
|
+
branches.add(c.branch);
|
|
261
|
+
}
|
|
239
262
|
if (chunkIndex.has(c.id)) error(cw, "duplicate chunk id");
|
|
240
263
|
chunkIndex.set(c.id, i);
|
|
241
264
|
if (c.hunks.length === 0) error(cw, "chunk has no hunks");
|
|
@@ -267,9 +290,9 @@ function validatePlan(raw, hunks, budget = CHUNK_LINE_BUDGET) {
|
|
|
267
290
|
}
|
|
268
291
|
}
|
|
269
292
|
});
|
|
270
|
-
const missing = hunks.filter((h) => !seen.has(h.id) && !skim.has(h.id) && !isIgnorable(h));
|
|
293
|
+
const missing = hunks.filter((h) => !seen.has(h.id) && !skim.has(h.id) && (split || !isIgnorable(h)));
|
|
271
294
|
if (missing.length)
|
|
272
|
-
error(where, `hunks not in any chunk or skim: ${missing.map(describe).join(", ")}`);
|
|
295
|
+
error(where, `hunks not in any ${split ? "PR" : "chunk or skim"}: ${missing.map(describe).join(", ")}`);
|
|
273
296
|
}
|
|
274
297
|
plan.links.forEach((l, i) => {
|
|
275
298
|
const lw = `link ${i + 1} (${l.from}\u2192${l.to})`;
|
|
@@ -277,7 +300,7 @@ function validatePlan(raw, hunks, budget = CHUNK_LINE_BUDGET) {
|
|
|
277
300
|
if (l.from === l.to) error(lw, "links a hunk to itself");
|
|
278
301
|
tooLong(lw, "label", l.label, BUDGET.linkLabel);
|
|
279
302
|
});
|
|
280
|
-
if (plan.features.length === 0) warn("features", "no features; the summary page will be empty");
|
|
303
|
+
if (plan.features.length === 0 && !split) warn("features", "no features; the summary page will be empty");
|
|
281
304
|
const inFeature = /* @__PURE__ */ new Set();
|
|
282
305
|
plan.features.forEach((f, i) => {
|
|
283
306
|
const fw = `feature ${i + 1} (${f.title})`;
|
|
@@ -646,9 +669,10 @@ function computeLinks(hunks) {
|
|
|
646
669
|
import { execFile } from "child_process";
|
|
647
670
|
import { promisify } from "util";
|
|
648
671
|
var exec = promisify(execFile);
|
|
649
|
-
async function git(cwd, args, input) {
|
|
672
|
+
async function git(cwd, args, input, env) {
|
|
650
673
|
const child = exec("git", ["-c", "core.quotePath=false", ...args], {
|
|
651
674
|
cwd,
|
|
675
|
+
...env ? { env: { ...process.env, ...env } } : {},
|
|
652
676
|
maxBuffer: 1024 * 1024 * 1024,
|
|
653
677
|
encoding: "utf8"
|
|
654
678
|
});
|
|
@@ -692,7 +716,8 @@ function diff(cwd, from, to) {
|
|
|
692
716
|
"--dst-prefix=b/",
|
|
693
717
|
from
|
|
694
718
|
];
|
|
695
|
-
if (to) args.
|
|
719
|
+
if (to === "index") args.splice(1, 0, "--cached");
|
|
720
|
+
else if (to) args.push(to);
|
|
696
721
|
return git(cwd, args);
|
|
697
722
|
}
|
|
698
723
|
async function untracked(cwd) {
|
|
@@ -726,7 +751,8 @@ function runPaths(root, name) {
|
|
|
726
751
|
plan: join(dir, "plan.json"),
|
|
727
752
|
state: join(dir, "review-state.json"),
|
|
728
753
|
review: join(dir, "review.md"),
|
|
729
|
-
links: join(dir, "links.json")
|
|
754
|
+
links: join(dir, "links.json"),
|
|
755
|
+
prs: join(dir, "prs.md")
|
|
730
756
|
};
|
|
731
757
|
}
|
|
732
758
|
async function ensureRunsDir(root) {
|
|
@@ -843,11 +869,15 @@ function buildHunks(files, attrGenerated = /* @__PURE__ */ new Set(), splitAt) {
|
|
|
843
869
|
async function extract(opts) {
|
|
844
870
|
const root = await repoRoot(opts.cwd);
|
|
845
871
|
const branch = await currentBranch(root);
|
|
846
|
-
const
|
|
847
|
-
const
|
|
872
|
+
const source = opts.source ?? (opts.working ? "working" : "pr");
|
|
873
|
+
const local = source === "staged" || source === "uncommitted";
|
|
874
|
+
if (local && opts.base) throw new Error(`--${source} always diffs against HEAD; drop the base`);
|
|
848
875
|
const headSha = await revParse(root, "HEAD");
|
|
849
|
-
const
|
|
850
|
-
const
|
|
876
|
+
const base = local ? "HEAD" : opts.base ?? await defaultBase(root);
|
|
877
|
+
const baseSha = local ? headSha : await revParse(root, base);
|
|
878
|
+
const mb = local ? headSha : await mergeBase(root, baseSha, headSha);
|
|
879
|
+
const working = source === "working" || source === "uncommitted";
|
|
880
|
+
const text = await diff(root, mb, source === "staged" ? "index" : working ? void 0 : headSha);
|
|
851
881
|
const files = parseDiff(text);
|
|
852
882
|
const attr = await generatedByAttr(root, files.map((f) => f.file));
|
|
853
883
|
const hunks = buildHunks(files, attr, opts.splitAt);
|
|
@@ -860,7 +890,8 @@ async function extract(opts) {
|
|
|
860
890
|
baseSha,
|
|
861
891
|
mergeBase: mb,
|
|
862
892
|
headSha,
|
|
863
|
-
working
|
|
893
|
+
working,
|
|
894
|
+
source,
|
|
864
895
|
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
865
896
|
stats: {
|
|
866
897
|
files: files.length,
|
|
@@ -875,7 +906,7 @@ async function extract(opts) {
|
|
|
875
906
|
await writeJson(paths.hunks, hunks);
|
|
876
907
|
await writeJson(paths.meta, meta);
|
|
877
908
|
await writeJson(paths.links, computeLinks(hunks));
|
|
878
|
-
const untracked2 =
|
|
909
|
+
const untracked2 = working ? await untracked(root) : [];
|
|
879
910
|
return { paths, meta, hunks, untracked: untracked2 };
|
|
880
911
|
}
|
|
881
912
|
|
|
@@ -890,11 +921,10 @@ function exportMarkdown(meta, hunks, plan, state) {
|
|
|
890
921
|
if (!view) return "";
|
|
891
922
|
const reviewed = new Set(state.reviewed);
|
|
892
923
|
const p = progress(view, reviewed);
|
|
893
|
-
const head = meta.working ? "working tree" : meta.headSha.slice(0, 8);
|
|
894
924
|
const pct = p.linesTotal ? Math.round(100 * p.linesDone / p.linesTotal) : 100;
|
|
895
925
|
const out = [];
|
|
896
926
|
out.push(`# Review: ${plan.title ?? meta.branch}`, "");
|
|
897
|
-
out.push(`${p.done}/${p.total} chunks \xB7 ${pct}% lines \xB7 \`${meta.base}\` \u2192 ${
|
|
927
|
+
out.push(`${p.done}/${p.total} chunks \xB7 ${pct}% lines \xB7 \`${meta.base}\` \u2192 ${headLabel(meta)}`, "");
|
|
898
928
|
if (plan.features.length) {
|
|
899
929
|
out.push("## Features", "");
|
|
900
930
|
for (const f of plan.features) {
|
|
@@ -930,9 +960,8 @@ function exportMarkdown(meta, hunks, plan, state) {
|
|
|
930
960
|
var short = (sha) => sha.slice(0, 8);
|
|
931
961
|
var clip = (s, n) => s.length > n ? `${s.slice(0, n - 1)}\u2026` : s;
|
|
932
962
|
function describeMeta(meta) {
|
|
933
|
-
const head = meta.working ? "working tree" : `HEAD ${short(meta.headSha)}`;
|
|
934
963
|
const { files, hunks, added, removed } = meta.stats;
|
|
935
|
-
return `run ${meta.run}: ${meta.base} (${short(meta.mergeBase)}) \u2192 ${
|
|
964
|
+
return `run ${meta.run}: ${meta.base} (${short(meta.mergeBase)}) \u2192 ${headLabel(meta)} on ${meta.branch}
|
|
936
965
|
${files} files, ${hunks} hunks, +${added} \u2212${removed}`;
|
|
937
966
|
}
|
|
938
967
|
function gist(h) {
|
|
@@ -1122,6 +1151,215 @@ async function serve(opts) {
|
|
|
1122
1151
|
};
|
|
1123
1152
|
}
|
|
1124
1153
|
|
|
1154
|
+
// cli/lib/stack.ts
|
|
1155
|
+
import { execFile as execFile2 } from "child_process";
|
|
1156
|
+
import { lstat, readFile as readFile3, readlink, rm } from "fs/promises";
|
|
1157
|
+
import { join as join3 } from "path";
|
|
1158
|
+
import { promisify as promisify2 } from "util";
|
|
1159
|
+
var execP = promisify2(execFile2);
|
|
1160
|
+
var ZERO = "0".repeat(40);
|
|
1161
|
+
function toLines(text) {
|
|
1162
|
+
const out = [];
|
|
1163
|
+
let i = 0;
|
|
1164
|
+
while (i < text.length) {
|
|
1165
|
+
const j = text.indexOf("\n", i);
|
|
1166
|
+
if (j < 0) {
|
|
1167
|
+
out.push(text.slice(i));
|
|
1168
|
+
break;
|
|
1169
|
+
}
|
|
1170
|
+
out.push(text.slice(i, j + 1));
|
|
1171
|
+
i = j + 1;
|
|
1172
|
+
}
|
|
1173
|
+
return out;
|
|
1174
|
+
}
|
|
1175
|
+
function sides(h) {
|
|
1176
|
+
const old = [];
|
|
1177
|
+
const neu = [];
|
|
1178
|
+
h.lines.forEach((l, i) => {
|
|
1179
|
+
const p = l[0];
|
|
1180
|
+
if (p === "\\") return;
|
|
1181
|
+
const text = l.slice(1) + (h.lines[i + 1]?.startsWith("\\") ? "" : "\n");
|
|
1182
|
+
if (p !== "+") old.push(text);
|
|
1183
|
+
if (p !== "-") neu.push(text);
|
|
1184
|
+
});
|
|
1185
|
+
return { old, new: neu };
|
|
1186
|
+
}
|
|
1187
|
+
function applyHunks(oldText, hunks) {
|
|
1188
|
+
const old = toLines(oldText);
|
|
1189
|
+
const out = [];
|
|
1190
|
+
let pos = 0;
|
|
1191
|
+
for (const h of [...hunks].sort((a, b) => a.oldStart - b.oldStart)) {
|
|
1192
|
+
const start = h.oldLines === 0 ? h.oldStart : h.oldStart - 1;
|
|
1193
|
+
const s = sides(h);
|
|
1194
|
+
if (start < pos || old.slice(start, start + h.oldLines).join("") !== s.old.join(""))
|
|
1195
|
+
throw new Error(`hunk ${h.id} doesn't match ${h.file}; re-run \`spark-joy extract\``);
|
|
1196
|
+
out.push(...old.slice(pos, start), ...s.new);
|
|
1197
|
+
pos = start + h.oldLines;
|
|
1198
|
+
}
|
|
1199
|
+
out.push(...old.slice(pos));
|
|
1200
|
+
return out.join("");
|
|
1201
|
+
}
|
|
1202
|
+
function groupByFile(hunks) {
|
|
1203
|
+
const groups = /* @__PURE__ */ new Map();
|
|
1204
|
+
for (const h of hunks) {
|
|
1205
|
+
const key = `${h.oldFile ?? ""}\0${h.file}`;
|
|
1206
|
+
let f = groups.get(key);
|
|
1207
|
+
if (!f) groups.set(key, f = { file: h.file, oldFile: h.oldFile, status: h.status, hunks: [] });
|
|
1208
|
+
f.hunks.push(h);
|
|
1209
|
+
}
|
|
1210
|
+
return [...groups.values()];
|
|
1211
|
+
}
|
|
1212
|
+
async function treeEntry(root, rev, path) {
|
|
1213
|
+
const out = await git(root, ["ls-tree", "-z", rev, "--", path]);
|
|
1214
|
+
const m = /^(\d+) \w+ ([0-9a-f]+)\t/.exec(out);
|
|
1215
|
+
return m ? { mode: m[1], sha: m[2] } : void 0;
|
|
1216
|
+
}
|
|
1217
|
+
async function targetEntry(root, meta, path) {
|
|
1218
|
+
const source = meta.source ?? (meta.working ? "working" : "pr");
|
|
1219
|
+
let e;
|
|
1220
|
+
if (source === "pr") e = await treeEntry(root, meta.headSha, path);
|
|
1221
|
+
else if (source === "staged") {
|
|
1222
|
+
const m = /^(\d+) ([0-9a-f]+) 0\t/.exec(await git(root, ["ls-files", "-s", "-z", "--", path]));
|
|
1223
|
+
if (m) e = { mode: m[1], sha: m[2] };
|
|
1224
|
+
} else {
|
|
1225
|
+
const full = join3(root, path);
|
|
1226
|
+
const st = await lstat(full);
|
|
1227
|
+
const content = st.isSymbolicLink() ? await readlink(full) : await readFile3(full);
|
|
1228
|
+
const mode = st.isSymbolicLink() ? "120000" : st.mode & 73 ? "100755" : "100644";
|
|
1229
|
+
const sha = st.isSymbolicLink() ? (await git(root, ["hash-object", "-w", "--stdin"], content)).trim() : (await git(root, ["hash-object", "-w", "--", path])).trim();
|
|
1230
|
+
e = { mode, sha };
|
|
1231
|
+
}
|
|
1232
|
+
if (!e) throw new Error(`${path} is missing from the ${source} source; re-run \`spark-joy extract\``);
|
|
1233
|
+
return e;
|
|
1234
|
+
}
|
|
1235
|
+
var blob = async (root, text) => (await git(root, ["hash-object", "-w", "--stdin"], text)).trim();
|
|
1236
|
+
async function fileUpdate(root, meta, f, done) {
|
|
1237
|
+
const applied = f.hunks.filter((h) => done.has(h.id));
|
|
1238
|
+
const all = applied.length === f.hunks.length;
|
|
1239
|
+
const textual = applied.filter((h) => h.lines.length > 0);
|
|
1240
|
+
const oldPath = f.oldFile ?? f.file;
|
|
1241
|
+
const remove = (p) => `0 ${ZERO} ${p}`;
|
|
1242
|
+
const lines = [];
|
|
1243
|
+
if (f.status === "renamed") lines.push(remove(oldPath));
|
|
1244
|
+
if (f.status === "deleted" && all) return [remove(f.file)];
|
|
1245
|
+
const old = f.status === "added" ? void 0 : await treeEntry(root, meta.mergeBase, oldPath);
|
|
1246
|
+
const binary = f.hunks.some((h) => h.flags.includes("binary"));
|
|
1247
|
+
const target = f.status === "deleted" ? void 0 : await targetEntry(root, meta, f.file);
|
|
1248
|
+
if (binary || all && f.hunks.every((h) => h.lines.length === 0)) {
|
|
1249
|
+
lines.push(`${target.mode} ${target.sha} ${f.file}`);
|
|
1250
|
+
return lines;
|
|
1251
|
+
}
|
|
1252
|
+
const oldText = old ? await git(root, ["cat-file", "blob", old.sha]) : "";
|
|
1253
|
+
const sha = await blob(root, applyHunks(oldText, textual));
|
|
1254
|
+
const mode = all && target ? target.mode : old?.mode ?? target.mode;
|
|
1255
|
+
lines.push(`${mode} ${sha} ${f.file}`);
|
|
1256
|
+
return lines;
|
|
1257
|
+
}
|
|
1258
|
+
async function buildStack(o) {
|
|
1259
|
+
const { root, meta, hunks } = o;
|
|
1260
|
+
const strategy = o.plan.strategies.find((s) => s.id === "split");
|
|
1261
|
+
if (o.plan.mode !== "split" || !strategy) throw new Error('not a split plan; set "mode": "split"');
|
|
1262
|
+
const head = await revParse(root, "HEAD");
|
|
1263
|
+
if (head !== meta.headSha) throw new Error("HEAD moved since extract; re-run `spark-joy extract`");
|
|
1264
|
+
const byId = new Map(hunks.map((h) => [h.id, h]));
|
|
1265
|
+
const files = groupByFile(hunks);
|
|
1266
|
+
const env = { GIT_INDEX_FILE: o.indexFile };
|
|
1267
|
+
await rm(o.indexFile, { force: true });
|
|
1268
|
+
await git(root, ["read-tree", meta.mergeBase], void 0, env);
|
|
1269
|
+
const done = /* @__PURE__ */ new Set();
|
|
1270
|
+
const prs = [];
|
|
1271
|
+
let parent = meta.mergeBase;
|
|
1272
|
+
try {
|
|
1273
|
+
for (const [i, chunk] of strategy.chunks.entries()) {
|
|
1274
|
+
for (const id of chunk.hunks) done.add(id);
|
|
1275
|
+
const ids = new Set(chunk.hunks);
|
|
1276
|
+
const touched = files.filter((f) => f.hunks.some((h) => ids.has(h.id)));
|
|
1277
|
+
const updates = (await Promise.all(touched.map((f) => fileUpdate(root, meta, f, done)))).flat();
|
|
1278
|
+
await git(root, ["update-index", "-z", "--index-info"], updates.map((u) => `${u}\0`).join(""), env);
|
|
1279
|
+
const tree = (await git(root, ["write-tree"], void 0, env)).trim();
|
|
1280
|
+
const message = `${chunk.title}
|
|
1281
|
+
|
|
1282
|
+
${chunk.intent}
|
|
1283
|
+
`;
|
|
1284
|
+
const commit = (await git(root, ["commit-tree", tree, "-p", parent], message)).trim();
|
|
1285
|
+
prs.push({
|
|
1286
|
+
n: i + 1,
|
|
1287
|
+
chunk,
|
|
1288
|
+
branch: branchName(o.prefix, i + 1, chunk),
|
|
1289
|
+
commit,
|
|
1290
|
+
lines: changedLines(chunk.hunks.map((id) => byId.get(id)).filter(Boolean)),
|
|
1291
|
+
files: touched.length
|
|
1292
|
+
});
|
|
1293
|
+
parent = commit;
|
|
1294
|
+
}
|
|
1295
|
+
} finally {
|
|
1296
|
+
await rm(o.indexFile, { force: true });
|
|
1297
|
+
}
|
|
1298
|
+
await verify(root, meta, parent);
|
|
1299
|
+
return { base: meta.mergeBase, prs };
|
|
1300
|
+
}
|
|
1301
|
+
async function verify(root, meta, top) {
|
|
1302
|
+
const source = meta.source ?? (meta.working ? "working" : "pr");
|
|
1303
|
+
let differ;
|
|
1304
|
+
if (source === "pr" || source === "staged") {
|
|
1305
|
+
const want = source === "pr" ? `${meta.headSha}^{tree}` : (await git(root, ["write-tree"])).trim();
|
|
1306
|
+
differ = await git(root, ["diff", "--name-only", "--no-ext-diff", "--no-textconv", `${top}^{tree}`, want]);
|
|
1307
|
+
} else differ = await git(root, ["diff", "--name-only", "--no-ext-diff", "--no-textconv", top]);
|
|
1308
|
+
const files = differ.split("\n").filter(Boolean);
|
|
1309
|
+
if (files.length)
|
|
1310
|
+
throw new Error(
|
|
1311
|
+
`the stack doesn't reproduce your changes in ${files.join(", ")}; re-run \`spark-joy extract\`. No branches were created`
|
|
1312
|
+
);
|
|
1313
|
+
}
|
|
1314
|
+
async function createBranches(root, stack, force) {
|
|
1315
|
+
const current = await currentBranch(root);
|
|
1316
|
+
const taken = [];
|
|
1317
|
+
for (const pr of stack.prs) {
|
|
1318
|
+
const ok = await git(root, ["check-ref-format", "--branch", pr.branch]).then(() => true, () => false);
|
|
1319
|
+
if (!ok) throw new Error(`invalid branch name ${pr.branch}`);
|
|
1320
|
+
if (pr.branch === current) throw new Error(`${pr.branch} is checked out; pick another --prefix`);
|
|
1321
|
+
const exists2 = await git(root, ["rev-parse", "--verify", "--quiet", `refs/heads/${pr.branch}`]).then(() => true, () => false);
|
|
1322
|
+
if (exists2) taken.push(pr.branch);
|
|
1323
|
+
}
|
|
1324
|
+
if (taken.length && !force) throw new Error(`branches already exist: ${taken.join(", ")}; pass --force to move them`);
|
|
1325
|
+
for (const pr of stack.prs) {
|
|
1326
|
+
const args = ["update-ref", "-m", "spark-joy split", `refs/heads/${pr.branch}`, pr.commit];
|
|
1327
|
+
await git(root, force ? args : [...args, ZERO]);
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
async function checkStack(root, stack, cmd, tmp) {
|
|
1331
|
+
const results = [];
|
|
1332
|
+
for (const pr of stack.prs) {
|
|
1333
|
+
const dir = join3(tmp, `pr-${pr.n}`);
|
|
1334
|
+
await git(root, ["worktree", "add", "--detach", "--quiet", dir, pr.commit]);
|
|
1335
|
+
try {
|
|
1336
|
+
const r = await execP(cmd, { cwd: dir, shell: true, maxBuffer: 64 * 1024 * 1024 }).then(
|
|
1337
|
+
(out) => ({ ok: true, out }),
|
|
1338
|
+
(e) => ({ ok: false, out: { stdout: e.stdout ?? "", stderr: e.stderr ?? "" } })
|
|
1339
|
+
);
|
|
1340
|
+
results.push({ n: pr.n, ok: r.ok, output: `${r.out.stdout}${r.out.stderr}` });
|
|
1341
|
+
} finally {
|
|
1342
|
+
await git(root, ["worktree", "remove", "--force", dir]).catch(() => void 0);
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
return results;
|
|
1346
|
+
}
|
|
1347
|
+
function prsMarkdown(meta, stack) {
|
|
1348
|
+
const target = meta.source === "staged" || meta.source === "uncommitted" ? meta.branch : meta.base.replace(/^origin\//, "");
|
|
1349
|
+
const out = [];
|
|
1350
|
+
stack.prs.forEach((pr, i) => {
|
|
1351
|
+
const c = pr.chunk;
|
|
1352
|
+
out.push(`## ${pr.n}. ${c.title}`, "");
|
|
1353
|
+
out.push(`\`${pr.branch}\` \u2192 \`${i === 0 ? target : stack.prs[i - 1].branch}\` \xB7 ${pr.lines} lines`, "");
|
|
1354
|
+
out.push(c.intent, "");
|
|
1355
|
+
for (const q of c.reviewFocus) out.push(`- ${q}`);
|
|
1356
|
+
if (c.reviewFocus.length) out.push("");
|
|
1357
|
+
const deps = c.dependsOn.map((d) => stack.prs.find((p) => p.chunk.id === d)?.n).filter((n) => n && n < pr.n - 1);
|
|
1358
|
+
if (deps.length) out.push(`Depends on ${deps.map((n) => `PR ${n}`).join(", ")}.`, "");
|
|
1359
|
+
});
|
|
1360
|
+
return out.join("\n");
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1125
1363
|
// cli/index.ts
|
|
1126
1364
|
var PKG_ROOT = fileURLToPath(new URL("..", import.meta.url));
|
|
1127
1365
|
var HELP = `spark-joy: break a huge PR into reviewable chunks
|
|
@@ -1131,6 +1369,8 @@ Usage: spark-joy <command> [options]
|
|
|
1131
1369
|
Commands:
|
|
1132
1370
|
extract [base] Diff merge-base(base, HEAD)..HEAD into hunks (base defaults to origin's default branch)
|
|
1133
1371
|
--working Diff against the working tree (uncommitted changes) instead of HEAD
|
|
1372
|
+
--staged Only staged changes (HEAD \u2192 index); for split
|
|
1373
|
+
--uncommitted Only uncommitted changes (HEAD \u2192 working tree); for split
|
|
1134
1374
|
--split-at <n> Split hunks longer than n lines (default 150)
|
|
1135
1375
|
outline Compact per-file, per-hunk overview of the diff
|
|
1136
1376
|
show <ids...> Print hunks in full; accepts h003, h003..h010, or a parent id like h012
|
|
@@ -1142,6 +1382,11 @@ Commands:
|
|
|
1142
1382
|
--port <n> Port to listen on (default 4848)
|
|
1143
1383
|
export Write review.md with progress, notes and the feature summary
|
|
1144
1384
|
--stdout Print instead of writing the file
|
|
1385
|
+
split Dry run of a split plan: build the stacked PRs and check they add up
|
|
1386
|
+
--check <cmd> Run cmd on each PR in a temporary worktree, e.g. "npm ci && npm test"
|
|
1387
|
+
--apply Create the branches (never pushes)
|
|
1388
|
+
--prefix <name> Branch prefix (default <branch>-split)
|
|
1389
|
+
--force Move branches that already exist
|
|
1145
1390
|
install-skill Install the Claude Code skill into ~/.claude/skills/spark-joy
|
|
1146
1391
|
--project Install into ./.claude/skills/spark-joy instead
|
|
1147
1392
|
|
|
@@ -1162,6 +1407,12 @@ async function main(argv, cwd = process.cwd()) {
|
|
|
1162
1407
|
options: {
|
|
1163
1408
|
run: { type: "string" },
|
|
1164
1409
|
working: { type: "boolean" },
|
|
1410
|
+
staged: { type: "boolean" },
|
|
1411
|
+
uncommitted: { type: "boolean" },
|
|
1412
|
+
apply: { type: "boolean" },
|
|
1413
|
+
prefix: { type: "string" },
|
|
1414
|
+
force: { type: "boolean" },
|
|
1415
|
+
check: { type: "string" },
|
|
1165
1416
|
"split-at": { type: "string" },
|
|
1166
1417
|
file: { type: "string" },
|
|
1167
1418
|
json: { type: "boolean" },
|
|
@@ -1174,7 +1425,7 @@ async function main(argv, cwd = process.cwd()) {
|
|
|
1174
1425
|
});
|
|
1175
1426
|
const [cmd, ...rest] = positionals;
|
|
1176
1427
|
if (values.version) {
|
|
1177
|
-
const pkg = JSON.parse(await
|
|
1428
|
+
const pkg = JSON.parse(await readFile4(join4(PKG_ROOT, "package.json"), "utf8"));
|
|
1178
1429
|
console.log(pkg.version);
|
|
1179
1430
|
return 0;
|
|
1180
1431
|
}
|
|
@@ -1186,9 +1437,11 @@ async function main(argv, cwd = process.cwd()) {
|
|
|
1186
1437
|
case "extract": {
|
|
1187
1438
|
const splitAt = values["split-at"] ? Number(values["split-at"]) : void 0;
|
|
1188
1439
|
if (splitAt !== void 0 && !(splitAt >= 40)) throw new Error("--split-at must be a number \u2265 40");
|
|
1189
|
-
const
|
|
1440
|
+
const flags = ["working", "staged", "uncommitted"].filter((f) => values[f]);
|
|
1441
|
+
if (flags.length > 1) throw new Error(`pick one of ${flags.map((f) => `--${f}`).join(", ")}`);
|
|
1442
|
+
const r = await extract({ cwd, base: rest[0], source: flags[0], run: values.run, splitAt });
|
|
1190
1443
|
console.log(describeMeta(r.meta));
|
|
1191
|
-
console.log(`wrote ${r.paths.dir}`);
|
|
1444
|
+
console.log(`wrote ${relative(cwd, r.paths.dir)}`);
|
|
1192
1445
|
if (r.hunks.length === 0) console.log("the diff is empty; nothing to review");
|
|
1193
1446
|
if (r.untracked.length)
|
|
1194
1447
|
console.log(`note: ${r.untracked.length} untracked file(s) are not included; \`git add -N <file>\` to include them`);
|
|
@@ -1231,7 +1484,7 @@ async function main(argv, cwd = process.cwd()) {
|
|
|
1231
1484
|
case "serve": {
|
|
1232
1485
|
const p = await run(cwd, values.run);
|
|
1233
1486
|
const port = values.port ? Number(values.port) : 4848;
|
|
1234
|
-
const s = await serve({ paths: p, appDir:
|
|
1487
|
+
const s = await serve({ paths: p, appDir: join4(PKG_ROOT, "dist", "app"), port });
|
|
1235
1488
|
console.log(`spark-joy reviewing ${p.name} at ${s.url} (Ctrl+C to stop)`);
|
|
1236
1489
|
await new Promise((resolve) => process.once("SIGINT", resolve));
|
|
1237
1490
|
await s.close();
|
|
@@ -1247,14 +1500,59 @@ async function main(argv, cwd = process.cwd()) {
|
|
|
1247
1500
|
if (values.stdout) process.stdout.write(md);
|
|
1248
1501
|
else {
|
|
1249
1502
|
await writeFile3(p.review, md);
|
|
1250
|
-
console.log(`wrote ${p.review}`);
|
|
1503
|
+
console.log(`wrote ${relative(cwd, p.review)}`);
|
|
1504
|
+
}
|
|
1505
|
+
return 0;
|
|
1506
|
+
}
|
|
1507
|
+
case "split": {
|
|
1508
|
+
const p = await run(cwd, values.run);
|
|
1509
|
+
const raw = await readPlanRaw(p);
|
|
1510
|
+
if (raw === void 0) throw new Error(`no plan yet; write ${p.plan}`);
|
|
1511
|
+
const hunks = await readHunks(p);
|
|
1512
|
+
const result = validatePlan(raw, hunks);
|
|
1513
|
+
if (!result.ok || !result.plan) throw new Error("the plan has errors; run `spark-joy validate`");
|
|
1514
|
+
const meta = await readMeta(p);
|
|
1515
|
+
const prefix = values.prefix ?? defaultPrefix(meta.branch);
|
|
1516
|
+
const stack = await buildStack({
|
|
1517
|
+
root: meta.repoRoot,
|
|
1518
|
+
meta,
|
|
1519
|
+
hunks,
|
|
1520
|
+
plan: result.plan,
|
|
1521
|
+
prefix,
|
|
1522
|
+
indexFile: join4(p.dir, "split.index")
|
|
1523
|
+
});
|
|
1524
|
+
for (const pr of stack.prs)
|
|
1525
|
+
console.log(`${String(pr.n).padStart(2)} ${pr.branch} ${pr.lines} lines, ${pr.files} files ${pr.chunk.title}`);
|
|
1526
|
+
console.log(`${stack.prs.length} PRs; the last one matches your changes exactly`);
|
|
1527
|
+
if (values.check !== void 0) {
|
|
1528
|
+
const tmp = await mkdtemp(join4(tmpdir(), "spark-joy-check-"));
|
|
1529
|
+
try {
|
|
1530
|
+
const results = await checkStack(meta.repoRoot, stack, values.check, tmp);
|
|
1531
|
+
for (const r of results) {
|
|
1532
|
+
console.log(`PR ${r.n}: ${r.ok ? "passed" : "failed"}`);
|
|
1533
|
+
if (!r.ok) console.log(r.output.trimEnd().split("\n").slice(-20).map((l) => ` ${l}`).join("\n"));
|
|
1534
|
+
}
|
|
1535
|
+
if (results.some((r) => !r.ok)) return 1;
|
|
1536
|
+
} finally {
|
|
1537
|
+
await rm2(tmp, { recursive: true, force: true });
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
await writeFile3(p.prs, prsMarkdown(meta, stack));
|
|
1541
|
+
if (!values.apply) {
|
|
1542
|
+
console.log(`wrote ${relative(cwd, p.prs)}
|
|
1543
|
+
nothing created; add --apply to create the branches`);
|
|
1544
|
+
return 0;
|
|
1251
1545
|
}
|
|
1546
|
+
await createBranches(meta.repoRoot, stack, !!values.force);
|
|
1547
|
+
console.log(`created ${stack.prs.length} branches; your working tree is unchanged. To publish:`);
|
|
1548
|
+
for (const pr of stack.prs) console.log(` git push -u origin ${pr.branch}`);
|
|
1549
|
+
console.log(`PR titles and descriptions: ${relative(cwd, p.prs)}`);
|
|
1252
1550
|
return 0;
|
|
1253
1551
|
}
|
|
1254
1552
|
case "install-skill": {
|
|
1255
|
-
const dir = values.project ?
|
|
1553
|
+
const dir = values.project ? join4(await repoRoot(cwd), ".claude", "skills", "spark-joy") : join4(homedir(), ".claude", "skills", "spark-joy");
|
|
1256
1554
|
await mkdir3(dir, { recursive: true });
|
|
1257
|
-
await copyFile(
|
|
1555
|
+
await copyFile(join4(PKG_ROOT, "plugin", "skills", "spark-joy", "SKILL.md"), join4(dir, "SKILL.md"));
|
|
1258
1556
|
console.log(`installed skill to ${dir}; use /spark-joy in Claude Code`);
|
|
1259
1557
|
return 0;
|
|
1260
1558
|
}
|