@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.
@@ -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}
@@ -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-XTDapI2X.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-BRFjS_MK.css">
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 readFile3, writeFile as writeFile3 } from "fs/promises";
3
- import { homedir } from "os";
4
- import { join as join3 } from "path";
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
- if (plan.strategies.length < 2)
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.push(to);
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 base = opts.base ?? await defaultBase(root);
847
- const baseSha = await revParse(root, base);
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 mb = await mergeBase(root, baseSha, headSha);
850
- const text = await diff(root, mb, opts.working ? void 0 : headSha);
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: !!opts.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 = opts.working ? await untracked(root) : [];
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 ${head}`, "");
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 ${head} on ${meta.branch}
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 readFile3(join3(PKG_ROOT, "package.json"), "utf8"));
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 r = await extract({ cwd, base: rest[0], working: values.working, run: values.run, splitAt });
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: join3(PKG_ROOT, "dist", "app"), port });
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 ? join3(await repoRoot(cwd), ".claude", "skills", "spark-joy") : join3(homedir(), ".claude", "skills", "spark-joy");
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(join3(PKG_ROOT, "plugin", "skills", "spark-joy", "SKILL.md"), join3(dir, "SKILL.md"));
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
  }