pi-graphite 0.2.3 → 0.3.1

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.
@@ -1,572 +0,0 @@
1
- import { spawn, type ChildProcessByStdio } from "node:child_process";
2
- import type { Readable } from "node:stream";
3
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
- import { DEFAULT_COMMAND_TIMEOUT_MS, killProcessGroup, runGt, safeNoninteractiveEnv } from "../lib/exec";
5
- import { ensureSuccess, renderText } from "../lib/result";
6
- import {
7
- CwdParam,
8
- StringEnum,
9
- Type,
10
- requireConfirm,
11
- type ToolReturn,
12
- } from "../lib/schema";
13
-
14
- /* ------------------------------ stack_view ------------------------------ */
15
-
16
- export function registerStackView(pi: ExtensionAPI) {
17
- pi.registerTool({
18
- name: "graphite_stack_view",
19
- label: "Graphite: stack view",
20
- description:
21
- "Show the Graphite stack as `gt log` (short/long/full). Read-only.",
22
- promptSnippet:
23
- "graphite_stack_view: read-only `gt log` of branches + dependencies",
24
- parameters: Type.Object({
25
- cwd: CwdParam,
26
- mode: Type.Optional(
27
- StringEnum(["short", "long", "full"] as const, {
28
- description: "short = `gt log short`, long = `gt log long`, full = `gt log`.",
29
- }),
30
- ),
31
- scope: Type.Optional(
32
- StringEnum(["current_stack", "default"] as const, {
33
- description:
34
- "current_stack adds --stack (only branches in the current stack). default shows all stacks off trunk (which is what `gt log` does by default — no separate 'all' flag is needed or supported).",
35
- }),
36
- ),
37
- showUntracked: Type.Optional(Type.Boolean()),
38
- reverse: Type.Optional(Type.Boolean()),
39
- steps: Type.Optional(Type.Integer({ minimum: 1 })),
40
- }),
41
- async execute(_id, p, signal): Promise<ToolReturn> {
42
- const sub =
43
- p.mode === "short"
44
- ? ["log", "short"]
45
- : p.mode === "long"
46
- ? ["log", "long"]
47
- : ["log"];
48
- const args = [...sub];
49
- if (p.scope === "current_stack") args.push("--stack");
50
- if (p.showUntracked) args.push("--show-untracked");
51
- if (p.reverse) args.push("--reverse");
52
- if (p.steps != null) args.push("--steps", String(p.steps));
53
-
54
- const label = `gt ${args.join(" ")}`;
55
- const r = await runGt(args, { cwd: p.cwd, signal });
56
- const f = await ensureSuccess(label, r, p.cwd);
57
- return {
58
- content: [{ type: "text", text: renderText(label, f) }],
59
- details: { result: f },
60
- };
61
- },
62
- });
63
- }
64
-
65
- /* ----------------------------- stack_restack ----------------------------- */
66
-
67
- export function registerStackRestack(pi: ExtensionAPI) {
68
- pi.registerTool({
69
- name: "graphite_stack_restack",
70
- label: "Graphite: restack",
71
- description:
72
- "Restack the current stack (or a chosen branch / scope) so each branch contains its parent's history. Local mutation only.",
73
- promptSnippet:
74
- "graphite_stack_restack: rebase stack so each branch contains parent history",
75
- promptGuidelines: [
76
- "Use graphite_stack_restack after editing a downstack branch to bring descendants up to date.",
77
- ],
78
- parameters: Type.Object({
79
- cwd: CwdParam,
80
- scope: Type.Optional(
81
- StringEnum(["current_stack", "downstack", "upstack", "only"] as const, {
82
- description:
83
- "Default current_stack (no scope flag). downstack=--downstack, upstack=--upstack, only=--only.",
84
- }),
85
- ),
86
- branch: Type.Optional(
87
- Type.String({ description: "Branch to restack from (default: current branch)." }),
88
- ),
89
- }),
90
- async execute(_id, p, signal): Promise<ToolReturn> {
91
- const args = ["restack"];
92
- if (p.branch) args.push("--branch", p.branch);
93
- if (p.scope === "downstack") args.push("--downstack");
94
- else if (p.scope === "upstack") args.push("--upstack");
95
- else if (p.scope === "only") args.push("--only");
96
-
97
- const label = `gt ${args.join(" ")}`;
98
- const r = await runGt(args, { cwd: p.cwd, signal });
99
- const f = await ensureSuccess(label, r, p.cwd);
100
- return {
101
- content: [{ type: "text", text: renderText(label, f) }],
102
- details: { result: f },
103
- };
104
- },
105
- });
106
- }
107
-
108
- /* --------------------------- stack_reorganize --------------------------- */
109
-
110
- export function registerStackReorganize(pi: ExtensionAPI) {
111
- pi.registerTool({
112
- name: "graphite_stack_reorganize",
113
- label: "Graphite: stack reorganize",
114
- description:
115
- "Reorganize the stack: move a branch onto a new parent, fold a branch into its parent, or split by file pathspec. NOTE: move_branch auto-restacks all descendants; if either source or onto contains a merge-of-trunk with a different tip than the other, conflicts are likely — use dryRun:true first to preview. Use graphite_stack_compose to linearize branches that diverge through merges. `gt reorder` is intentionally not exposed (editor-only).",
116
- promptSnippet:
117
- "graphite_stack_reorganize: move / fold / split-by-file branches",
118
- parameters: Type.Object({
119
- cwd: CwdParam,
120
- action: StringEnum(["move_branch", "fold", "split_by_file"] as const),
121
- onto: Type.Optional(Type.String({ description: "Target parent branch (action=move_branch)." })),
122
- source: Type.Optional(
123
- Type.String({ description: "Source branch to move (default current, action=move_branch)." }),
124
- ),
125
- onlyMove: Type.Optional(
126
- Type.Boolean({ description: "action=move_branch: leave descendants behind (--only)." }),
127
- ),
128
- dryRun: Type.Optional(
129
- Type.Boolean({
130
- description:
131
- "action=move_branch: don't run `gt move`. Instead, run a `git merge-tree` simulation between source tip and onto tip and report whether conflicts are likely. Use before applying.",
132
- }),
133
- ),
134
- foldKeep: Type.Optional(
135
- Type.Boolean({ description: "action=fold: keep current branch name (--keep)." }),
136
- ),
137
- foldStack: Type.Optional(
138
- Type.Boolean({ description: "action=fold: fold the entire stack into one branch (--stack)." }),
139
- ),
140
- foldClose: Type.Optional(
141
- Type.Boolean({ description: "action=fold: close associated PRs on GitHub (--close). Requires confirmRemote." }),
142
- ),
143
- filePatterns: Type.Optional(
144
- Type.Array(Type.String(), {
145
- description: "action=split_by_file: one or more pathspecs (-f). Repeat-flag style.",
146
- }),
147
- ),
148
- confirmRemote: Type.Optional(Type.Boolean()),
149
- }),
150
- async execute(_id, p, signal): Promise<ToolReturn> {
151
- let args: string[];
152
- if (p.action === "move_branch") {
153
- if (!p.onto) throw new Error("action=move_branch requires `onto`.");
154
- if (p.dryRun) {
155
- const sim = await simulateMove(p.cwd, p.onto, p.source, signal);
156
- return {
157
- content: [{ type: "text", text: sim.text }],
158
- details: { action: "move_branch", dryRun: true, ...sim.details },
159
- };
160
- }
161
- args = ["move", "--onto", p.onto];
162
- if (p.source) args.push("--source", p.source);
163
- if (p.onlyMove) args.push("--only");
164
- } else if (p.action === "fold") {
165
- if (p.foldClose) requireConfirm(p.confirmRemote, "fold --close");
166
- args = ["fold"];
167
- if (p.foldKeep) args.push("--keep");
168
- if (p.foldStack) args.push("--stack");
169
- if (p.foldClose) args.push("--close");
170
- } else {
171
- if (!p.filePatterns || p.filePatterns.length === 0) {
172
- throw new Error(
173
- "action=split_by_file requires `filePatterns` (one or more pathspecs).",
174
- );
175
- }
176
- args = ["split", "--by-file"];
177
- for (const pat of p.filePatterns) args.push("-f", pat);
178
- }
179
-
180
- const label = `gt ${args.join(" ")}`;
181
- const r = await runGt(args, { cwd: p.cwd, signal });
182
- const f = await ensureSuccess(label, r, p.cwd);
183
- return {
184
- content: [{ type: "text", text: renderText(label, f) }],
185
- details: { action: p.action, result: f },
186
- };
187
- },
188
- });
189
- }
190
-
191
- /* ----------------------------- helpers ----------------------------- */
192
-
193
- interface RunOut {
194
- exitCode: number;
195
- stdout: string;
196
- stderr: string;
197
- }
198
-
199
- function runCmd(
200
- cmd: string,
201
- args: string[],
202
- cwd: string,
203
- signal?: AbortSignal,
204
- ): Promise<RunOut> {
205
- return new Promise((resolve) => {
206
- let out = "";
207
- let err = "";
208
- let child: ChildProcessByStdio<null, Readable, Readable>;
209
- try {
210
- child = spawn(cmd, args, {
211
- cwd,
212
- env: safeNoninteractiveEnv(),
213
- stdio: ["ignore", "pipe", "pipe"],
214
- detached: process.platform !== "win32",
215
- });
216
- } catch (e) {
217
- resolve({ exitCode: -1, stdout: "", stderr: (e as Error).message });
218
- return;
219
- }
220
- child.stdout.on("data", (d) => {
221
- out += d.toString();
222
- });
223
- child.stderr.on("data", (d) => {
224
- err += d.toString();
225
- });
226
- let killed = false;
227
- const killChild = () => {
228
- killed = true;
229
- killProcessGroup(child, "SIGTERM");
230
- setTimeout(() => killProcessGroup(child, "SIGKILL"), 1500).unref?.();
231
- };
232
- const timeout = setTimeout(killChild, DEFAULT_COMMAND_TIMEOUT_MS);
233
- timeout.unref?.();
234
- const onAbort = killChild;
235
- signal?.addEventListener("abort", onAbort, { once: true });
236
- child.on("error", (e) => {
237
- clearTimeout(timeout);
238
- signal?.removeEventListener("abort", onAbort);
239
- resolve({ exitCode: -1, stdout: out, stderr: err + e.message });
240
- });
241
- child.on("close", (code) => {
242
- clearTimeout(timeout);
243
- signal?.removeEventListener("abort", onAbort);
244
- resolve({ exitCode: killed ? -1 : (code ?? -1), stdout: out, stderr: err });
245
- });
246
- });
247
- }
248
-
249
- async function gitRevParse(
250
- cwd: string,
251
- ref: string,
252
- signal?: AbortSignal,
253
- ): Promise<string | null> {
254
- const r = await runCmd("git", ["rev-parse", "--verify", ref], cwd, signal);
255
- if (r.exitCode !== 0) return null;
256
- return r.stdout.trim() || null;
257
- }
258
-
259
- async function simulateMove(
260
- cwd: string,
261
- onto: string,
262
- source: string | undefined,
263
- signal: AbortSignal | undefined,
264
- ): Promise<{ text: string; details: Record<string, unknown> }> {
265
- // Resolve source default = current branch
266
- let src = source;
267
- if (!src) {
268
- const r = await runCmd(
269
- "git",
270
- ["branch", "--show-current"],
271
- cwd,
272
- signal,
273
- );
274
- src = r.stdout.trim();
275
- }
276
- if (!src) {
277
- return {
278
- text: "[move_branch dry-run] could not resolve source branch (detached HEAD?).",
279
- details: { ok: false },
280
- };
281
- }
282
- const ontoSha = await gitRevParse(cwd, onto, signal);
283
- const srcSha = await gitRevParse(cwd, src, signal);
284
- if (!ontoSha || !srcSha) {
285
- return {
286
- text: `[move_branch dry-run] unknown ref(s): onto=${onto}(${ontoSha ?? "?"}) src=${src}(${srcSha ?? "?"})`,
287
- details: { ok: false, src, onto },
288
- };
289
- }
290
- // Find merge base
291
- const mb = await runCmd(
292
- "git",
293
- ["merge-base", ontoSha, srcSha],
294
- cwd,
295
- signal,
296
- );
297
- const base = mb.stdout.trim();
298
- // Use merge-tree to simulate. Modern git: `git merge-tree --write-tree --merge-base <base> <onto> <src>`
299
- const mt = await runCmd(
300
- "git",
301
- [
302
- "merge-tree",
303
- "--write-tree",
304
- "--name-only",
305
- "--merge-base",
306
- base || ontoSha,
307
- ontoSha,
308
- srcSha,
309
- ],
310
- cwd,
311
- signal,
312
- );
313
- // Exit code 0 = clean. Non-zero = conflicts; stdout lists conflicted paths (after tree oid on first line).
314
- let conflictedFiles: string[] = [];
315
- let clean = mt.exitCode === 0;
316
- if (!clean) {
317
- const lines = mt.stdout.split("\n").map((s) => s.trim()).filter(Boolean);
318
- // First line is the merge tree oid; remaining are conflicted paths.
319
- if (lines.length > 1) conflictedFiles = lines.slice(1);
320
- else conflictedFiles = lines;
321
- }
322
- // Also count commits each side carries beyond the merge base
323
- const ahead = await runCmd(
324
- "git",
325
- ["rev-list", "--count", `${base || ontoSha}..${srcSha}`],
326
- cwd,
327
- signal,
328
- );
329
- const behind = await runCmd(
330
- "git",
331
- ["rev-list", "--count", `${base || ontoSha}..${ontoSha}`],
332
- cwd,
333
- signal,
334
- );
335
- const lines = [
336
- `[move_branch dry-run] source=${src} -> onto=${onto}`,
337
- `merge-base = ${base || "(none)"}`,
338
- `src ahead of base by ${ahead.stdout.trim() || "?"} commits; onto ahead by ${behind.stdout.trim() || "?"} commits`,
339
- clean
340
- ? `merge-tree: CLEAN — \`gt move\` is likely safe.`
341
- : `merge-tree: CONFLICTS in ${conflictedFiles.length} path(s):`,
342
- ];
343
- if (!clean) lines.push(...conflictedFiles.map((f) => ` - ${f}`));
344
- if (!clean) {
345
- lines.push(
346
- "",
347
- "Suggestion: either resolve drift first (e.g. rebase source onto onto's trunk merge) or use graphite_stack_compose to linearize by cherry-picking unique commits in order.",
348
- );
349
- }
350
- return {
351
- text: lines.join("\n"),
352
- details: {
353
- ok: true,
354
- clean,
355
- src,
356
- onto,
357
- mergeBase: base || null,
358
- srcAhead: Number(ahead.stdout.trim()) || 0,
359
- ontoAhead: Number(behind.stdout.trim()) || 0,
360
- conflictedFiles,
361
- },
362
- };
363
- }
364
-
365
- /* ----------------------------- stack_compose ----------------------------- */
366
-
367
- export function registerStackCompose(pi: ExtensionAPI) {
368
- pi.registerTool({
369
- name: "graphite_stack_compose",
370
- label: "Graphite: stack compose",
371
- description:
372
- "Linearize a set of branches into a fresh stack by cherry-picking each branch's unique commits (base..branch, no-merges) in the given order. Each successive branch is rebuilt on top of the previous, then tracked by Graphite with the explicit parent. Use this when `gt move` would conflict because branches contain divergent merges of trunk. Halts and surfaces the failing branch on cherry-pick conflict; continue with safe git env, then call again with `resume:true`.",
373
- promptSnippet:
374
- "graphite_stack_compose: rebuild branches as a linear stack on top of base via cherry-pick",
375
- promptGuidelines: [
376
- "Run with dryRun:true first to see the commits that would be cherry-picked per branch.",
377
- "If a cherry-pick halts on conflict, resolve in git, run `GIT_EDITOR=true EDITOR=true VISUAL=true git cherry-pick --continue`, then re-invoke graphite_stack_compose with resume:true to finish the remaining branches.",
378
- ],
379
- parameters: Type.Object({
380
- cwd: CwdParam,
381
- base: Type.String({
382
- description:
383
- "Base branch the new stack will sit on top of (e.g. 'main', 'origin/main'). Must exist.",
384
- }),
385
- order: Type.Array(Type.String(), {
386
- description:
387
- "Branch names in bottom-up order. order[0] is rebuilt on `base`, order[1] on order[0], etc.",
388
- }),
389
- dryRun: Type.Optional(
390
- Type.Boolean({
391
- description: "If true, just list commits per branch without modifying anything.",
392
- }),
393
- ),
394
- includeMerges: Type.Optional(
395
- Type.Boolean({
396
- description:
397
- "Include merge commits when computing unique commits (default false; merges are usually trunk-syncs you do not want to replay).",
398
- }),
399
- ),
400
- confirmDestructive: Type.Optional(
401
- Type.Boolean({
402
- description:
403
- "Required when dryRun is false: rewrites each branch ref to a new history. Local-only but irreversible without reflog.",
404
- }),
405
- ),
406
- resume: Type.Optional(
407
- Type.Boolean({
408
- description:
409
- "Skip branches whose tip already matches the expected linearized history (used after resolving a cherry-pick conflict).",
410
- }),
411
- ),
412
- }),
413
- async execute(_id, p, signal): Promise<ToolReturn> {
414
- if (!p.order || p.order.length === 0) {
415
- throw new Error("graphite_stack_compose requires non-empty `order`.");
416
- }
417
- if (!p.dryRun) requireConfirm(p.confirmDestructive, "stack_compose (rewrites local branch refs)");
418
-
419
- // Validate base + all branches resolve.
420
- const refs = [p.base, ...p.order];
421
- const resolved: Record<string, string> = {};
422
- for (const ref of refs) {
423
- const sha = await gitRevParse(p.cwd, ref, signal);
424
- if (!sha) {
425
- throw new Error(`graphite_stack_compose: cannot resolve ref \`${ref}\`.`);
426
- }
427
- resolved[ref] = sha;
428
- }
429
-
430
- // Compute unique commit list per branch relative to base.
431
- const revListArgs = (base: string, branch: string) => {
432
- const a = ["rev-list", "--reverse"];
433
- if (!p.includeMerges) a.push("--no-merges");
434
- a.push(`${base}..${branch}`);
435
- return a;
436
- };
437
-
438
- const plan: Array<{ branch: string; commits: string[]; subjects: string[] }> = [];
439
- for (const br of p.order) {
440
- const r = await runCmd("git", revListArgs(p.base, br), p.cwd, signal);
441
- if (r.exitCode !== 0) {
442
- throw new Error(
443
- `git rev-list failed for ${p.base}..${br}: ${r.stderr.trim()}`,
444
- );
445
- }
446
- const commits = r.stdout.split("\n").map((s) => s.trim()).filter(Boolean);
447
- const subjects: string[] = [];
448
- for (const c of commits) {
449
- const s = await runCmd(
450
- "git",
451
- ["log", "-1", "--format=%h %s", c],
452
- p.cwd,
453
- signal,
454
- );
455
- subjects.push(s.stdout.trim());
456
- }
457
- plan.push({ branch: br, commits, subjects });
458
- }
459
-
460
- const planText = plan
461
- .map((step, i) => {
462
- const parent = i === 0 ? p.base : p.order[i - 1];
463
- const lines = [`# ${step.branch} (parent=${parent}, ${step.commits.length} commit(s))`];
464
- if (step.commits.length === 0) lines.push(" (no unique commits — branch will be reset to parent)");
465
- for (const s of step.subjects) lines.push(` ${s}`);
466
- return lines.join("\n");
467
- })
468
- .join("\n\n");
469
-
470
- if (p.dryRun) {
471
- return {
472
- content: [
473
- {
474
- type: "text",
475
- text: `[stack_compose dry-run] base=${p.base}\n\n${planText}\n\n(Re-run with dryRun:false, confirmDestructive:true to apply.)`,
476
- },
477
- ],
478
- details: { dryRun: true, base: p.base, plan },
479
- };
480
- }
481
-
482
- // Apply.
483
- const log: string[] = [`base=${p.base}`];
484
- let parent = p.base;
485
- for (const step of plan) {
486
- // Skip in resume mode if branch tip already equals expected linearized state:
487
- // heuristic — if current branch's parent in graphite already equals `parent`
488
- // and its commit set matches step.commits, skip. We use a simpler check:
489
- // resume=true + cherry-pick state absent + branch reachable from parent
490
- // with same commit subjects in order.
491
- if (p.resume) {
492
- const reachable = await runCmd(
493
- "git",
494
- ["merge-base", "--is-ancestor", parent, step.branch],
495
- p.cwd,
496
- signal,
497
- );
498
- // git returns 0 if ancestor.
499
- if (reachable.exitCode === 0) {
500
- // Compare commits between parent..branch and step.commits subjects.
501
- const cur = await runCmd("git", revListArgs(parent, step.branch), p.cwd, signal);
502
- const curCommits = cur.stdout.split("\n").map((s) => s.trim()).filter(Boolean);
503
- if (curCommits.length === step.commits.length) {
504
- log.push(`skip ${step.branch} (already linearized)`);
505
- parent = step.branch;
506
- continue;
507
- }
508
- }
509
- }
510
-
511
- // Reset branch to parent.
512
- const reset = await runCmd(
513
- "git",
514
- ["checkout", "-B", step.branch, parent],
515
- p.cwd,
516
- signal,
517
- );
518
- if (reset.exitCode !== 0) {
519
- throw new Error(
520
- `compose: failed to checkout ${step.branch} on ${parent}: ${reset.stderr.trim()}`,
521
- );
522
- }
523
- log.push(`reset ${step.branch} -> ${parent}`);
524
- // Cherry-pick each commit.
525
- for (const c of step.commits) {
526
- const cp = await runCmd(
527
- "git",
528
- ["cherry-pick", "--allow-empty", c],
529
- p.cwd,
530
- signal,
531
- );
532
- if (cp.exitCode !== 0) {
533
- const msg = [
534
- `compose: cherry-pick of ${c} onto ${step.branch} halted with conflicts.`,
535
- cp.stdout.trim(),
536
- cp.stderr.trim(),
537
- "",
538
- "Resolve conflicts in git, run `GIT_EDITOR=true EDITOR=true VISUAL=true git cherry-pick --continue` until clean, then call graphite_stack_compose again with resume:true and the same order/base.",
539
- ]
540
- .filter(Boolean)
541
- .join("\n");
542
- throw new Error(msg);
543
- }
544
- log.push(` cherry-pick ${c.slice(0, 7)}`);
545
- }
546
- // Track in graphite with explicit parent.
547
- const track = await runGt(
548
- ["track", step.branch, "--parent", parent, "--force"],
549
- { cwd: p.cwd, signal },
550
- );
551
- if (track.exitCode !== 0) {
552
- log.push(
553
- ` warning: gt track failed for ${step.branch}: ${track.stderr.trim() || track.stdout.trim()}`,
554
- );
555
- } else {
556
- log.push(` tracked parent=${parent}`);
557
- }
558
- parent = step.branch;
559
- }
560
-
561
- return {
562
- content: [
563
- {
564
- type: "text",
565
- text: `[stack_compose] OK\n\n${log.join("\n")}\n\n${planText}`,
566
- },
567
- ],
568
- details: { dryRun: false, base: p.base, plan, log },
569
- };
570
- },
571
- });
572
- }