pi-graphite 0.2.2 → 0.2.3

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 CHANGED
@@ -34,7 +34,8 @@ pi -e /path/to/pi-graphite
34
34
  | `graphite_repo` | repo | `gt trunk`, `gt init`, `gt log short` |
35
35
  | `graphite_stack_view` | stack | `gt log` / `gt log short` / `gt log long` |
36
36
  | `graphite_stack_restack` | stack | `gt restack` (+ `--branch/--downstack/--upstack/--only`) |
37
- | `graphite_stack_reorganize` | stack | `gt move`, `gt fold`, `gt split --by-file` |
37
+ | `graphite_stack_reorganize` | stack | `gt move`, `gt fold`, `gt split --by-file` (move_branch supports `dryRun` via `git merge-tree` simulation) |
38
+ | `graphite_stack_compose` | stack | linearize branches by cherry-picking `base..branch` unique commits in order, then `gt track` each |
38
39
  | `graphite_branch_inspect` | branch | `gt info` (+ `gt parent`, `gt children`) |
39
40
  | `graphite_branch_create` | branch | `gt create` |
40
41
  | `graphite_branch_update` | branch | `gt modify`, `gt absorb`, `gt squash`, `gt pop`, `gt rename`, `gt delete` |
@@ -42,13 +43,15 @@ pi -e /path/to/pi-graphite
42
43
  | `graphite_branch_navigate` | branch | `gt checkout`, `gt up`, `gt down`, `gt top`, `gt bottom` |
43
44
  | `graphite_remote_sync` | remote | `gt sync`, `gt get` |
44
45
  | `graphite_pr_submit` | PR | `gt submit` (dry-run by default) |
45
- | `graphite_pr_lifecycle` | PR | `gt pr`, `gt merge`, `gt unlink` |
46
+ | `graphite_pr_lifecycle` | PR | `gh pr view --json url`, `gt merge`, `gt unlink` |
46
47
  | `graphite_recovery` | recovery | `gt continue`, `gt abort`, `gt undo` |
47
48
 
48
49
  ## Conventions
49
50
 
50
51
  - Every tool requires an absolute `cwd`.
51
52
  - `gt` is invoked with `--cwd <cwd> --no-interactive` by default. No shell strings.
53
+ - Editor, pager, and browser env are forced safe (`GT_EDITOR=true`, `GT_PAGER=`, `BROWSER=true`, etc.); commands have a hard timeout.
54
+ - Interactive editor/browser/hunk paths are rejected (`edit:true`, `stage:"patch"`, `patch:true`, `editMode:"web"`, `view:true`, interactive trunk add, merge `confirm:true`).
52
55
  - Remote / destructive operations require explicit ack flags:
53
56
  - `graphite_pr_submit` defaults to `--dry-run`; `apply: true` needs `confirmRemote: true`.
54
57
  - `graphite_pr_lifecycle action=merge` defaults to `--dry-run`; `apply: true` needs `confirmRemote: true`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-graphite",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "Structured pi tools for the Graphite (gt) CLI.",
5
5
  "keywords": [
6
6
  "pi",
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  registerStackView,
6
6
  registerStackRestack,
7
7
  registerStackReorganize,
8
+ registerStackCompose,
8
9
  } from "./tools/stack";
9
10
  import {
10
11
  registerBranchInspect,
@@ -26,6 +27,7 @@ import { registerRecovery } from "./tools/recovery";
26
27
  * graphite_stack_view
27
28
  * graphite_stack_restack
28
29
  * graphite_stack_reorganize
30
+ * graphite_stack_compose
29
31
  * graphite_branch_inspect
30
32
  * graphite_branch_create
31
33
  * graphite_branch_update
@@ -39,6 +41,7 @@ import { registerRecovery } from "./tools/recovery";
39
41
  * Conventions:
40
42
  * - Every tool requires absolute `cwd`.
41
43
  * - `gt` is invoked with --cwd <cwd> --no-interactive by default.
44
+ * - Editor/pager/browser env is forced safe; interactive hunk/editor/browser paths are rejected.
42
45
  * - Remote / destructive operations require explicit `confirmRemote` /
43
46
  * `confirmDestructive` flags. Submit/merge default to dry-run.
44
47
  * - Output is ANSI-stripped and truncated to ~50KB / 2000 lines.
@@ -49,6 +52,7 @@ export default function (pi: ExtensionAPI) {
49
52
  registerStackView(pi);
50
53
  registerStackRestack(pi);
51
54
  registerStackReorganize(pi);
55
+ registerStackCompose(pi);
52
56
 
53
57
  registerBranchInspect(pi);
54
58
  registerBranchCreate(pi);
package/src/lib/exec.ts CHANGED
@@ -5,8 +5,10 @@ import { resolve as resolvePath } from "node:path";
5
5
  export interface GtRunOptions {
6
6
  cwd: string;
7
7
  signal?: AbortSignal;
8
- /** Extra env vars merged into process.env */
8
+ /** Extra env vars merged into process.env. Safety vars always win. */
9
9
  env?: Record<string, string>;
10
+ /** Hard timeout in ms. Default 120s. */
11
+ timeoutMs?: number;
10
12
  }
11
13
 
12
14
  export interface GtRunResult {
@@ -23,6 +25,43 @@ export interface GtRunResult {
23
25
  const ANSI = /\x1b\[[0-9;]*[a-zA-Z]/g;
24
26
  const MAX_BYTES = 50 * 1024;
25
27
  const MAX_LINES = 2000;
28
+ export const DEFAULT_COMMAND_TIMEOUT_MS = 120_000;
29
+
30
+ /** Env guardrails: disable editors, pagers, browsers. Must override caller env. */
31
+ export const SAFE_NONINTERACTIVE_ENV: Record<string, string> = {
32
+ GT_EDITOR: "true",
33
+ TEST_GT_EDITOR: "true",
34
+ GIT_EDITOR: "true",
35
+ EDITOR: "true",
36
+ VISUAL: "true",
37
+ GIT_SEQUENCE_EDITOR: "true",
38
+ GT_PAGER: "",
39
+ GIT_PAGER: "cat",
40
+ PAGER: "cat",
41
+ LESS: "FRX",
42
+ BROWSER: "true",
43
+ GH_BROWSER: "true",
44
+ };
45
+
46
+ export function safeNoninteractiveEnv(extra?: Record<string, string>): NodeJS.ProcessEnv {
47
+ return {
48
+ ...process.env,
49
+ ...(extra ?? {}),
50
+ ...SAFE_NONINTERACTIVE_ENV,
51
+ };
52
+ }
53
+
54
+ export function killProcessGroup(child: { pid?: number; kill(signal?: NodeJS.Signals): boolean }, signal: NodeJS.Signals): void {
55
+ try {
56
+ if (child.pid && process.platform !== "win32") {
57
+ process.kill(-child.pid, signal);
58
+ return;
59
+ }
60
+ } catch {}
61
+ try {
62
+ child.kill(signal);
63
+ } catch {}
64
+ }
26
65
 
27
66
  export function stripAnsi(s: string): string {
28
67
  return s.replace(ANSI, "");
@@ -68,8 +107,11 @@ export async function runGt(
68
107
  try {
69
108
  child = spawn("gt", args, {
70
109
  cwd,
71
- env: { ...process.env, ...(opts.env ?? {}) },
110
+ // Force any editor/pager/browser invocation to no-op instead of
111
+ // hanging. Safety vars override opts.env by design.
112
+ env: safeNoninteractiveEnv(opts.env),
72
113
  stdio: ["ignore", "pipe", "pipe"],
114
+ detached: process.platform !== "win32",
73
115
  });
74
116
  } catch (e) {
75
117
  resolve({
@@ -88,6 +130,16 @@ export async function runGt(
88
130
  let stdout = "";
89
131
  let stderr = "";
90
132
  let killed = false;
133
+ let settled = false;
134
+
135
+ const killChild = () => {
136
+ killed = true;
137
+ killProcessGroup(child, "SIGTERM");
138
+ setTimeout(() => killProcessGroup(child, "SIGKILL"), 1500).unref?.();
139
+ };
140
+
141
+ const timeout = setTimeout(killChild, opts.timeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS);
142
+ timeout.unref?.();
91
143
 
92
144
  child.stdout?.on("data", (d) => {
93
145
  stdout += d.toString();
@@ -98,20 +150,14 @@ export async function runGt(
98
150
  if (stderr.length > MAX_BYTES * 4) stderr = stderr.slice(-MAX_BYTES * 2);
99
151
  });
100
152
 
101
- const onAbort = () => {
102
- killed = true;
103
- try {
104
- child.kill("SIGTERM");
105
- setTimeout(() => {
106
- try {
107
- child.kill("SIGKILL");
108
- } catch {}
109
- }, 1500).unref?.();
110
- } catch {}
111
- };
153
+ const onAbort = killChild;
112
154
  opts.signal?.addEventListener("abort", onAbort, { once: true });
113
155
 
114
156
  child.on("error", (err) => {
157
+ if (settled) return;
158
+ settled = true;
159
+ clearTimeout(timeout);
160
+ opts.signal?.removeEventListener("abort", onAbort);
115
161
  resolve({
116
162
  command: "gt",
117
163
  args,
@@ -119,12 +165,15 @@ export async function runGt(
119
165
  exitCode: -1,
120
166
  stdout: sanitizeBranding(stripAnsi(stdout)),
121
167
  stderr: sanitizeBranding(stripAnsi(stderr)),
122
- timedOut: false,
168
+ timedOut: killed,
123
169
  spawnError: err.message,
124
170
  });
125
171
  });
126
172
 
127
173
  child.on("close", (code) => {
174
+ if (settled) return;
175
+ settled = true;
176
+ clearTimeout(timeout);
128
177
  opts.signal?.removeEventListener("abort", onAbort);
129
178
  resolve({
130
179
  command: "gt",
package/src/lib/result.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
- import type { GtRunResult } from "./exec";
2
+ import { DEFAULT_COMMAND_TIMEOUT_MS, killProcessGroup, safeNoninteractiveEnv, type GtRunResult } from "./exec";
3
3
 
4
4
  /**
5
5
  * Structured failure hints. Only populated when the underlying `gt` command
@@ -140,17 +140,33 @@ function execText(cmd: string, args: string[], cwd: string): Promise<string> {
140
140
  let out = "";
141
141
  let child;
142
142
  try {
143
- child = spawn(cmd, args, { cwd, stdio: ["ignore", "pipe", "ignore"] });
143
+ child = spawn(cmd, args, {
144
+ cwd,
145
+ env: safeNoninteractiveEnv(),
146
+ stdio: ["ignore", "pipe", "ignore"],
147
+ detached: process.platform !== "win32",
148
+ });
144
149
  } catch {
145
150
  resolve("");
146
151
  return;
147
152
  }
153
+ const timeout = setTimeout(() => {
154
+ killProcessGroup(child, "SIGTERM");
155
+ setTimeout(() => killProcessGroup(child, "SIGKILL"), 1500).unref?.();
156
+ }, DEFAULT_COMMAND_TIMEOUT_MS);
157
+ timeout.unref?.();
148
158
  child.stdout.on("data", (d) => {
149
159
  out += d.toString();
150
160
  if (out.length > 4096) out = out.slice(-4096);
151
161
  });
152
- child.on("error", () => resolve(""));
153
- child.on("close", () => resolve(out));
162
+ child.on("error", () => {
163
+ clearTimeout(timeout);
164
+ resolve("");
165
+ });
166
+ child.on("close", () => {
167
+ clearTimeout(timeout);
168
+ resolve(out);
169
+ });
154
170
  });
155
171
  }
156
172
 
package/src/lib/schema.ts CHANGED
@@ -9,10 +9,11 @@ export const StageMode = StringEnum([
9
9
  "none",
10
10
  "all",
11
11
  "update",
12
- "patch",
13
12
  ] as const);
14
13
 
15
- export function stageArgs(mode: "none" | "all" | "update" | "patch"): string[] {
14
+ export type StageModeValue = "none" | "all" | "update";
15
+
16
+ export function stageArgs(mode: StageModeValue): string[] {
16
17
  switch (mode) {
17
18
  case "none":
18
19
  return [];
@@ -20,9 +21,9 @@ export function stageArgs(mode: "none" | "all" | "update" | "patch"): string[] {
20
21
  return ["--all"];
21
22
  case "update":
22
23
  return ["--update"];
23
- case "patch":
24
- return ["--patch"];
25
24
  }
25
+ const _exhaustive: never = mode;
26
+ return _exhaustive;
26
27
  }
27
28
 
28
29
  /** Common cwd param. Required so we always pass an absolute path to gt. */
@@ -13,6 +13,7 @@ import {
13
13
  Type,
14
14
  requireConfirm,
15
15
  stageArgs,
16
+ type StageModeValue,
16
17
  type ToolReturn,
17
18
  } from "../lib/schema";
18
19
 
@@ -175,7 +176,8 @@ export function registerBranchCreate(pi: ExtensionAPI) {
175
176
  const args = ["create"];
176
177
  if (p.name) args.push(p.name);
177
178
  if (p.message) args.push("--message", p.message);
178
- args.push(...stageArgs((p.stage ?? "none") as "none" | "all" | "update" | "patch"));
179
+ if ((p.stage as string | undefined) === "patch") throw new Error("stage:'patch' is disabled; use stage:'all' or stage:'update'.");
180
+ args.push(...stageArgs((p.stage ?? "none") as StageModeValue));
179
181
  if (p.onto) args.push("--onto", p.onto);
180
182
  if (p.insert) args.push("--insert");
181
183
  args.push(p.ai ? "--ai" : "--no-ai");
@@ -203,6 +205,7 @@ export function registerBranchUpdate(pi: ExtensionAPI) {
203
205
  promptGuidelines: [
204
206
  "Use graphite_branch_update with action=absorb (dryRun:true first) to distribute staged hunks across downstack commits.",
205
207
  "graphite_branch_update with action=delete and close:true requires confirmRemote.",
208
+ "Editor and patch/hunk modes are disabled. Pass explicit `message` where needed.",
206
209
  ],
207
210
  parameters: Type.Object({
208
211
  cwd: CwdParam,
@@ -220,7 +223,7 @@ export function registerBranchUpdate(pi: ExtensionAPI) {
220
223
  into: Type.Optional(
221
224
  Type.String({ description: "action=amend: amend into a downstack branch (`gt modify --into`)." }),
222
225
  ),
223
- edit: Type.Optional(Type.Boolean({ description: "Open editor for commit message." })),
226
+ edit: Type.Optional(Type.Boolean({ description: "Rejected: editor mode is disabled for agent safety." })),
224
227
  resetAuthor: Type.Optional(Type.Boolean({ description: "action=amend: reset commit author." })),
225
228
  newName: Type.Optional(Type.String({ description: "action=rename: new branch name." })),
226
229
  branch: Type.Optional(Type.String({ description: "action=delete: branch name to delete." })),
@@ -233,30 +236,32 @@ export function registerBranchUpdate(pi: ExtensionAPI) {
233
236
  dryRun: Type.Optional(
234
237
  Type.Boolean({ description: "action=absorb: dry-run only (default true)." }),
235
238
  ),
236
- patch: Type.Optional(Type.Boolean({ description: "action=absorb: pick hunks (--patch)." })),
239
+ patch: Type.Optional(Type.Boolean({ description: "Rejected: interactive hunk mode is disabled." })),
237
240
  confirmRemote: Type.Optional(Type.Boolean()),
238
241
  }),
239
242
  async execute(_id, p, signal): Promise<ToolReturn> {
240
243
  let args: string[];
244
+ if (p.edit) throw new Error("edit:true is disabled; pass an explicit message instead.");
245
+ if (p.patch) throw new Error("patch:true is disabled; interactive hunk selection is not exposed.");
246
+ if ((p.stage as string | undefined) === "patch") throw new Error("stage:'patch' is disabled; use stage:'all' or stage:'update'.");
241
247
 
242
248
  switch (p.action) {
243
249
  case "amend": {
244
250
  args = ["modify"];
245
- args.push(...stageArgs((p.stage ?? "none") as "none" | "all" | "update" | "patch"));
251
+ args.push(...stageArgs((p.stage ?? "none") as StageModeValue));
246
252
  if (p.message) args.push("--message", p.message);
247
- if (p.edit) args.push("--edit");
253
+ else args.push("--no-edit");
248
254
  if (p.resetAuthor) args.push("--reset-author");
249
255
  if (p.into) args.push("--into", p.into);
250
256
  break;
251
257
  }
252
258
  case "new_commit": {
253
- if (!p.message && !p.edit) {
254
- throw new Error("action=new_commit requires `message` or edit=true.");
259
+ if (!p.message) {
260
+ throw new Error("action=new_commit requires `message` (editor mode disabled).");
255
261
  }
256
262
  args = ["modify", "--commit"];
257
- args.push(...stageArgs((p.stage ?? "none") as "none" | "all" | "update" | "patch"));
258
- if (p.message) args.push("--message", p.message);
259
- if (p.edit) args.push("--edit");
263
+ args.push(...stageArgs((p.stage ?? "none") as StageModeValue));
264
+ args.push("--message", p.message);
260
265
  break;
261
266
  }
262
267
  case "absorb": {
@@ -264,15 +269,14 @@ export function registerBranchUpdate(pi: ExtensionAPI) {
264
269
  args = ["absorb"];
265
270
  if (dryRun) args.push("--dry-run");
266
271
  else args.push("--force");
267
- const stage = (p.stage ?? "none") as "none" | "all" | "update" | "patch";
272
+ const stage = (p.stage ?? "none") as StageModeValue;
268
273
  if (stage === "all") args.push("--all");
269
- if (p.patch) args.push("--patch");
270
274
  break;
271
275
  }
272
276
  case "squash": {
273
277
  args = ["squash"];
274
278
  if (p.message) args.push("--message", p.message);
275
- if (p.edit) args.push("--edit");
279
+ else args.push("--no-edit");
276
280
  break;
277
281
  }
278
282
  case "pop": {
package/src/tools/pr.ts CHANGED
@@ -1,5 +1,7 @@
1
+ import { spawn, type ChildProcessByStdio } from "node:child_process";
2
+ import type { Readable } from "node:stream";
1
3
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import { runGt } from "../lib/exec";
4
+ import { DEFAULT_COMMAND_TIMEOUT_MS, killProcessGroup, runGt, safeNoninteractiveEnv } from "../lib/exec";
3
5
  import { ensureSuccess, renderText } from "../lib/result";
4
6
  import {
5
7
  CwdParam,
@@ -17,6 +19,72 @@ function shellQuote(s: string): string {
17
19
  return `'${s.replace(/'/g, `'"'"'`)}'`;
18
20
  }
19
21
 
22
+ interface CmdResult {
23
+ command: string;
24
+ args: string[];
25
+ cwd: string;
26
+ exitCode: number;
27
+ stdout: string;
28
+ stderr: string;
29
+ timedOut: boolean;
30
+ spawnError?: string;
31
+ }
32
+
33
+ function runGh(args: string[], cwd: string, signal?: AbortSignal): Promise<CmdResult> {
34
+ return new Promise((resolve) => {
35
+ let child: ChildProcessByStdio<null, Readable, Readable>;
36
+ try {
37
+ child = spawn("gh", args, {
38
+ cwd,
39
+ env: safeNoninteractiveEnv(),
40
+ stdio: ["ignore", "pipe", "pipe"],
41
+ detached: process.platform !== "win32",
42
+ });
43
+ } catch (e) {
44
+ resolve({ command: "gh", args, cwd, exitCode: -1, stdout: "", stderr: "", timedOut: false, spawnError: (e as Error).message });
45
+ return;
46
+ }
47
+
48
+ let stdout = "";
49
+ let stderr = "";
50
+ let killed = false;
51
+ let settled = false;
52
+ const killChild = () => {
53
+ killed = true;
54
+ killProcessGroup(child, "SIGTERM");
55
+ setTimeout(() => killProcessGroup(child, "SIGKILL"), 1500).unref?.();
56
+ };
57
+ const timeout = setTimeout(killChild, DEFAULT_COMMAND_TIMEOUT_MS);
58
+ timeout.unref?.();
59
+ const onAbort = killChild;
60
+ signal?.addEventListener("abort", onAbort, { once: true });
61
+
62
+ child.stdout.on("data", (d) => (stdout += d.toString()));
63
+ child.stderr.on("data", (d) => (stderr += d.toString()));
64
+ child.on("error", (e) => {
65
+ if (settled) return;
66
+ settled = true;
67
+ clearTimeout(timeout);
68
+ signal?.removeEventListener("abort", onAbort);
69
+ resolve({ command: "gh", args, cwd, exitCode: -1, stdout, stderr, timedOut: killed, spawnError: e.message });
70
+ });
71
+ child.on("close", (code) => {
72
+ if (settled) return;
73
+ settled = true;
74
+ clearTimeout(timeout);
75
+ signal?.removeEventListener("abort", onAbort);
76
+ resolve({ command: "gh", args, cwd, exitCode: code ?? -1, stdout, stderr, timedOut: killed });
77
+ });
78
+ });
79
+ }
80
+
81
+ function renderGhText(label: string, r: CmdResult): string {
82
+ const lines = [`[${label}] fail`, `$ gh ${r.args.join(" ")}`, `# cwd=${r.cwd} exit=${r.exitCode}${r.timedOut ? " (aborted)" : ""}${r.spawnError ? ` (spawn-error: ${r.spawnError})` : ""}`];
83
+ if (r.stdout.trim()) lines.push("--- stdout ---", r.stdout.trimEnd());
84
+ if (r.stderr.trim()) lines.push("--- stderr ---", r.stderr.trimEnd());
85
+ return lines.join("\n");
86
+ }
87
+
20
88
  export function registerPrSubmit(pi: ExtensionAPI) {
21
89
  pi.registerTool({
22
90
  name: "graphite_pr_submit",
@@ -39,7 +107,7 @@ export function registerPrSubmit(pi: ExtensionAPI) {
39
107
  stack: Type.Optional(
40
108
  Type.Boolean({
41
109
  description:
42
- "true => --stack (include descendants). false => --no-stack. Omitted => default (gt prompts based on config).",
110
+ "true => --stack (include descendants). false => --no-stack. Omitted => gt default behavior.",
43
111
  }),
44
112
  ),
45
113
  branch: Type.Optional(Type.String({ description: "Run from this branch (--branch)." })),
@@ -53,9 +121,9 @@ export function registerPrSubmit(pi: ExtensionAPI) {
53
121
  comment: Type.Optional(Type.String({ description: "--comment <msg>" })),
54
122
  targetTrunk: Type.Optional(Type.String()),
55
123
  editMode: Type.Optional(
56
- StringEnum(["none", "cli", "web"] as const, {
124
+ StringEnum(["none", "cli"] as const, {
57
125
  description:
58
- "none (default) => --no-edit; cli => --edit --cli; web => --web. Affects PR metadata prompting.",
126
+ "none (default) => --no-edit; cli => --edit --cli. Browser/web edit mode is disabled.",
59
127
  }),
60
128
  ),
61
129
  ai: Type.Optional(
@@ -67,7 +135,7 @@ export function registerPrSubmit(pi: ExtensionAPI) {
67
135
  Type.Boolean({ description: "--force (instead of default --force-with-lease). Requires confirmRemote." }),
68
136
  ),
69
137
  ignoreOutOfSyncTrunk: Type.Optional(Type.Boolean()),
70
- view: Type.Optional(Type.Boolean({ description: "--view (open PR in browser after submit)." })),
138
+ view: Type.Optional(Type.Boolean({ description: "Rejected: browser viewing is disabled." })),
71
139
  confirmRemote: Type.Optional(Type.Boolean()),
72
140
 
73
141
  title: Type.Optional(
@@ -87,6 +155,8 @@ export function registerPrSubmit(pi: ExtensionAPI) {
87
155
  const apply = p.apply === true;
88
156
  if (apply) requireConfirm(p.confirmRemote, "gt submit (push branches + create/update PRs)");
89
157
  if (p.forcePush) requireConfirm(p.confirmRemote, "gt submit --force");
158
+ if ((p.editMode as string | undefined) === "web") throw new Error("editMode:'web' is disabled; browser launch is not exposed.");
159
+ if (p.view) throw new Error("view:true is disabled; browser launch is not exposed. Use graphite_pr_lifecycle action='view_url'.");
90
160
 
91
161
  const wantsCustomMetadata = p.title != null || p.body != null;
92
162
 
@@ -116,13 +186,11 @@ export function registerPrSubmit(pi: ExtensionAPI) {
116
186
  const editMode = wantsCustomMetadata ? "none" : (p.editMode ?? "none");
117
187
  if (editMode === "none") args.push("--no-edit");
118
188
  else if (editMode === "cli") args.push("--edit", "--cli");
119
- else if (editMode === "web") args.push("--web");
120
189
 
121
190
  args.push(p.ai ? "--ai" : "--no-ai");
122
191
 
123
192
  if (p.forcePush) args.push("--force");
124
193
  if (p.ignoreOutOfSyncTrunk) args.push("--ignore-out-of-sync-trunk");
125
- if (p.view) args.push("--view");
126
194
 
127
195
  const label = `gt ${args.join(" ")}`;
128
196
  const r = await runGt(args, { cwd: p.cwd, signal });
@@ -175,15 +243,15 @@ export function registerPrLifecycle(pi: ExtensionAPI) {
175
243
  name: "graphite_pr_lifecycle",
176
244
  label: "Graphite: PR lifecycle",
177
245
  description:
178
- "PR lifecycle actions: open the PR/stack page in a browser, merge the stack via Graphite, or unlink a branch from its PR.",
246
+ "PR lifecycle actions: return PR URL, merge the stack via Graphite, or unlink a branch from its PR.",
179
247
  promptSnippet:
180
- "graphite_pr_lifecycle: open_url | merge | unlink for a PR/branch",
248
+ "graphite_pr_lifecycle: view_url | merge | unlink for a PR/branch",
181
249
  parameters: Type.Object({
182
250
  cwd: CwdParam,
183
- action: StringEnum(["open_url", "merge", "unlink"] as const),
251
+ action: StringEnum(["view_url", "merge", "unlink"] as const),
184
252
  branch: Type.Optional(Type.String({ description: "Branch name or PR number." })),
185
253
  stack: Type.Optional(
186
- Type.Boolean({ description: "action=open_url: open stack page (--stack)." }),
254
+ Type.Boolean({ description: "Rejected: stack browser page is disabled." }),
187
255
  ),
188
256
  apply: Type.Optional(
189
257
  Type.Boolean({
@@ -192,23 +260,28 @@ export function registerPrLifecycle(pi: ExtensionAPI) {
192
260
  ),
193
261
  confirm: Type.Optional(
194
262
  Type.Boolean({
195
- description: "action=merge: pass --confirm so gt prompts before merging each branch.",
263
+ description: "Rejected: interactive confirmation prompts are disabled.",
196
264
  }),
197
265
  ),
198
266
  confirmRemote: Type.Optional(Type.Boolean()),
199
267
  }),
200
268
  async execute(_id, p, signal): Promise<ToolReturn> {
201
269
  let args: string[];
202
- if (p.action === "open_url") {
203
- args = ["pr"];
204
- if (p.branch) args.push(p.branch);
205
- if (p.stack) args.push("--stack");
270
+ if (p.action === "view_url") {
271
+ if (p.stack) throw new Error("stack:true is disabled; browser stack page is not exposed.");
272
+ const branchArgs = p.branch ? [p.branch] : [];
273
+ const r = await runGh(["pr", "view", ...branchArgs, "--json", "url", "--jq", ".url"], p.cwd, signal);
274
+ if (r.exitCode !== 0) throw new Error(renderGhText("gh pr view", r));
275
+ return {
276
+ content: [{ type: "text", text: r.stdout.trim() }],
277
+ details: { action: p.action, url: r.stdout.trim(), result: r },
278
+ };
206
279
  } else if (p.action === "merge") {
207
280
  const apply = p.apply === true;
208
281
  if (apply) requireConfirm(p.confirmRemote, "gt merge (merges PRs on GitHub)");
209
282
  args = ["merge"];
210
283
  if (!apply) args.push("--dry-run");
211
- if (p.confirm) args.push("--confirm");
284
+ if (p.confirm) throw new Error("confirm:true is disabled; interactive confirmation prompts are not exposed.");
212
285
  } else {
213
286
  args = ["unlink"];
214
287
  if (p.branch) args.push(p.branch);
@@ -1,8 +1,71 @@
1
+ import { spawn, type ChildProcessByStdio } from "node:child_process";
2
+ import type { Readable } from "node:stream";
1
3
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import { runGt } from "../lib/exec";
4
+ import { DEFAULT_COMMAND_TIMEOUT_MS, killProcessGroup, runGt, safeNoninteractiveEnv } from "../lib/exec";
3
5
  import { ensureSuccess, renderText } from "../lib/result";
4
6
  import { CwdParam, StringEnum, Type, type ToolReturn } from "../lib/schema";
5
7
 
8
+ function runGit(
9
+ args: string[],
10
+ cwd: string,
11
+ signal?: AbortSignal,
12
+ ): Promise<{ exitCode: number; stdout: string; stderr: string }> {
13
+ return new Promise((resolve) => {
14
+ let out = "";
15
+ let err = "";
16
+ let child: ChildProcessByStdio<null, Readable, Readable>;
17
+ try {
18
+ child = spawn("git", args, {
19
+ cwd,
20
+ env: safeNoninteractiveEnv(),
21
+ stdio: ["ignore", "pipe", "pipe"],
22
+ detached: process.platform !== "win32",
23
+ });
24
+ } catch (e) {
25
+ resolve({ exitCode: -1, stdout: "", stderr: (e as Error).message });
26
+ return;
27
+ }
28
+ child.stdout.on("data", (d) => (out += d.toString()));
29
+ child.stderr.on("data", (d) => (err += d.toString()));
30
+ let killed = false;
31
+ const killChild = () => {
32
+ killed = true;
33
+ killProcessGroup(child, "SIGTERM");
34
+ setTimeout(() => killProcessGroup(child, "SIGKILL"), 1500).unref?.();
35
+ };
36
+ const timeout = setTimeout(killChild, DEFAULT_COMMAND_TIMEOUT_MS);
37
+ timeout.unref?.();
38
+ const onAbort = killChild;
39
+ signal?.addEventListener("abort", onAbort, { once: true });
40
+ child.on("error", (e) => {
41
+ clearTimeout(timeout);
42
+ signal?.removeEventListener("abort", onAbort);
43
+ resolve({ exitCode: -1, stdout: out, stderr: err + e.message });
44
+ });
45
+ child.on("close", (code) => {
46
+ clearTimeout(timeout);
47
+ signal?.removeEventListener("abort", onAbort);
48
+ resolve({ exitCode: killed ? -1 : (code ?? -1), stdout: out, stderr: err });
49
+ });
50
+ });
51
+ }
52
+
53
+ /** Return list of tracked files that still contain conflict markers. */
54
+ async function findUnresolvedConflictMarkers(
55
+ cwd: string,
56
+ signal?: AbortSignal,
57
+ ): Promise<string[]> {
58
+ // grep for the canonical 7-char start marker at line start across tracked files.
59
+ // `git grep` is cheap and respects gitignore / tracked-only by default.
60
+ const r = await runGit(
61
+ ["grep", "-l", "-E", "^<{7} "],
62
+ cwd,
63
+ signal,
64
+ );
65
+ if (r.exitCode > 1) return []; // git grep returns 1 for no matches, >1 is real error
66
+ return r.stdout.split("\n").map((s) => s.trim()).filter(Boolean);
67
+ }
68
+
6
69
  export function registerRecovery(pi: ExtensionAPI) {
7
70
  pi.registerTool({
8
71
  name: "graphite_recovery",
@@ -21,6 +84,12 @@ export function registerRecovery(pi: ExtensionAPI) {
21
84
  stageAll: Type.Optional(
22
85
  Type.Boolean({ description: "action=continue: stage all changes first (--all)." }),
23
86
  ),
87
+ allowConflictMarkers: Type.Optional(
88
+ Type.Boolean({
89
+ description:
90
+ "action=continue: bypass the pre-flight check that refuses to continue when tracked files still contain `<<<<<<<` conflict markers. Default false. Only set true if you know the markers are intentional (e.g. tests).",
91
+ }),
92
+ ),
24
93
  force: Type.Optional(
25
94
  Type.Boolean({ description: "action=abort|undo: skip confirmation prompt (--force)." }),
26
95
  ),
@@ -28,10 +97,22 @@ export function registerRecovery(pi: ExtensionAPI) {
28
97
  async execute(_id, p, signal): Promise<ToolReturn> {
29
98
  let args: string[];
30
99
  switch (p.action) {
31
- case "continue":
100
+ case "continue": {
101
+ if (!p.allowConflictMarkers) {
102
+ const dirty = await findUnresolvedConflictMarkers(p.cwd, signal);
103
+ if (dirty.length) {
104
+ throw new Error(
105
+ `graphite_recovery: refusing to continue — ${dirty.length} tracked file(s) still contain conflict markers (\`<<<<<<<\`):\n` +
106
+ dirty.map((f) => ` - ${f}`).join("\n") +
107
+ `\n\nResolve each file (remove <<<<<<< / ======= / >>>>>>> blocks), then re-run. ` +
108
+ `If markers are intentional, pass allowConflictMarkers:true.`,
109
+ );
110
+ }
111
+ }
32
112
  args = ["continue"];
33
113
  if (p.stageAll) args.push("--all");
34
114
  break;
115
+ }
35
116
  case "abort":
36
117
  args = ["abort"];
37
118
  if (p.force) args.push("--force");
package/src/tools/repo.ts CHANGED
@@ -9,13 +9,13 @@ const params = Type.Object({
9
9
  trunk: Type.Optional(
10
10
  Type.String({
11
11
  description:
12
- "Trunk branch name. Required for action=set_trunk. Optional for action=init (otherwise gt prompts).",
12
+ "Trunk branch name. Required for action=set_trunk. Optional for action=init (gt default behavior).",
13
13
  }),
14
14
  ),
15
15
  addAdditionalTrunk: Type.Optional(
16
16
  Type.Boolean({
17
17
  description:
18
- "If true with action=set_trunk, add an additional trunk via `gt trunk --add` instead of replacing.",
18
+ "Rejected: `gt trunk --add` is interactive and not exposed to agents.",
19
19
  }),
20
20
  ),
21
21
  reset: Type.Optional(
@@ -30,7 +30,7 @@ export function registerRepo(pi: ExtensionAPI) {
30
30
  name: "graphite_repo",
31
31
  label: "Graphite: repo",
32
32
  description:
33
- "Repo-level Graphite operations: status snapshot (log + trunk), init, set/add trunk, and show config.",
33
+ "Repo-level Graphite operations: status snapshot (log + trunk), init, set trunk, and show config.",
34
34
  promptSnippet:
35
35
  "graphite_repo: inspect repo state, initialize Graphite, configure trunk(s)",
36
36
  parameters: params,
@@ -85,15 +85,7 @@ export function registerRepo(pi: ExtensionAPI) {
85
85
  details: { result: f },
86
86
  };
87
87
  }
88
- // `gt trunk --add` is interactive (prompts for the new trunk name).
89
- // With --no-interactive this will fail; surface that failure rather
90
- // than silently misleading the caller.
91
- const r = await runGt(["trunk", "--add"], { cwd, signal });
92
- const f = await ensureSuccess("gt trunk --add", r, cwd);
93
- return {
94
- content: [{ type: "text", text: renderText("gt trunk --add", f) }],
95
- details: { result: f },
96
- };
88
+ throw new Error("gt trunk --add is interactive; not exposed to agents.");
97
89
  }
98
90
 
99
91
  // show_config
@@ -1,5 +1,7 @@
1
+ import { spawn, type ChildProcessByStdio } from "node:child_process";
2
+ import type { Readable } from "node:stream";
1
3
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import { runGt } from "../lib/exec";
4
+ import { DEFAULT_COMMAND_TIMEOUT_MS, killProcessGroup, runGt, safeNoninteractiveEnv } from "../lib/exec";
3
5
  import { ensureSuccess, renderText } from "../lib/result";
4
6
  import {
5
7
  CwdParam,
@@ -27,9 +29,9 @@ export function registerStackView(pi: ExtensionAPI) {
27
29
  }),
28
30
  ),
29
31
  scope: Type.Optional(
30
- StringEnum(["all_trunks", "current_stack", "default"] as const, {
32
+ StringEnum(["current_stack", "default"] as const, {
31
33
  description:
32
- "all_trunks adds --all (only supported on mode=full/default). current_stack adds --stack. default does neither.",
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).",
33
35
  }),
34
36
  ),
35
37
  showUntracked: Type.Optional(Type.Boolean()),
@@ -37,16 +39,6 @@ export function registerStackView(pi: ExtensionAPI) {
37
39
  steps: Type.Optional(Type.Integer({ minimum: 1 })),
38
40
  }),
39
41
  async execute(_id, p, signal): Promise<ToolReturn> {
40
- // Empirically, current gt rejects `--all` on `gt log short` / `gt log long`
41
- // ("Unknown argument: all"). Block that combination at the tool layer
42
- // instead of exposing it to the model.
43
- if (p.scope === "all_trunks" && (p.mode === "short" || p.mode === "long")) {
44
- throw new Error(
45
- "graphite_stack_view: scope='all_trunks' is not supported with mode='short'/'long' " +
46
- "(current `gt` rejects `--all` on those forms). Omit `mode` (or use mode='full') to use --all.",
47
- );
48
- }
49
-
50
42
  const sub =
51
43
  p.mode === "short"
52
44
  ? ["log", "short"]
@@ -54,7 +46,6 @@ export function registerStackView(pi: ExtensionAPI) {
54
46
  ? ["log", "long"]
55
47
  : ["log"];
56
48
  const args = [...sub];
57
- if (p.scope === "all_trunks") args.push("--all");
58
49
  if (p.scope === "current_stack") args.push("--stack");
59
50
  if (p.showUntracked) args.push("--show-untracked");
60
51
  if (p.reverse) args.push("--reverse");
@@ -121,7 +112,7 @@ export function registerStackReorganize(pi: ExtensionAPI) {
121
112
  name: "graphite_stack_reorganize",
122
113
  label: "Graphite: stack reorganize",
123
114
  description:
124
- "Reorganize the stack: move a branch onto a new parent, fold a branch into its parent, or split by file pathspec. `gt reorder` is intentionally not exposed (editor-only).",
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).",
125
116
  promptSnippet:
126
117
  "graphite_stack_reorganize: move / fold / split-by-file branches",
127
118
  parameters: Type.Object({
@@ -134,6 +125,12 @@ export function registerStackReorganize(pi: ExtensionAPI) {
134
125
  onlyMove: Type.Optional(
135
126
  Type.Boolean({ description: "action=move_branch: leave descendants behind (--only)." }),
136
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
+ ),
137
134
  foldKeep: Type.Optional(
138
135
  Type.Boolean({ description: "action=fold: keep current branch name (--keep)." }),
139
136
  ),
@@ -154,6 +151,13 @@ export function registerStackReorganize(pi: ExtensionAPI) {
154
151
  let args: string[];
155
152
  if (p.action === "move_branch") {
156
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
+ }
157
161
  args = ["move", "--onto", p.onto];
158
162
  if (p.source) args.push("--source", p.source);
159
163
  if (p.onlyMove) args.push("--only");
@@ -183,3 +187,386 @@ export function registerStackReorganize(pi: ExtensionAPI) {
183
187
  },
184
188
  });
185
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
+ }