pi-graphite 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,47 @@ 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
+ // gt-gh treats this as "invoked from Graphite Interactive" and forces
45
+ // non-interactive behavior regardless of argv. Other gt builds should
46
+ // ignore it if unsupported; keep --no-interactive argv guards too.
47
+ GRAPHITE_INTERACTIVE: "1",
48
+ };
49
+
50
+ export function safeNoninteractiveEnv(extra?: Record<string, string>): NodeJS.ProcessEnv {
51
+ return {
52
+ ...process.env,
53
+ ...(extra ?? {}),
54
+ ...SAFE_NONINTERACTIVE_ENV,
55
+ };
56
+ }
57
+
58
+ export function killProcessGroup(child: { pid?: number; kill(signal?: NodeJS.Signals): boolean }, signal: NodeJS.Signals): void {
59
+ try {
60
+ if (child.pid && process.platform !== "win32") {
61
+ process.kill(-child.pid, signal);
62
+ return;
63
+ }
64
+ } catch {}
65
+ try {
66
+ child.kill(signal);
67
+ } catch {}
68
+ }
26
69
 
27
70
  export function stripAnsi(s: string): string {
28
71
  return s.replace(ANSI, "");
@@ -47,12 +90,27 @@ export function truncateOutput(s: string): string {
47
90
  return `${kept}\n... [truncated: ${s.length - MAX_BYTES} more bytes]`;
48
91
  }
49
92
 
93
+ /**
94
+ * Tokens that re-enable interactive flows in gt and must never appear in
95
+ * rawArgs. Tool authors should not pass these directly, and user-supplied
96
+ * strings should be routed through argv helpers (assertSafeRef / flagEq)
97
+ * so values starting with `-` can never reach this list. We still scan
98
+ * defensively here as belt-and-braces.
99
+ */
100
+ const FORBIDDEN_RAW_TOKENS = new Set<string>([
101
+ "--interactive",
102
+ "--interactive-rebase",
103
+ ]);
104
+
50
105
  /**
51
106
  * Run `gt` with structured args. Never builds a shell string.
52
107
  *
53
- * - Always injects --cwd <abs>.
54
- * - Always injects --no-interactive. No escape hatch by design: agent-driven
55
- * tools must never block on a TTY prompt.
108
+ * - Always injects --cwd <abs> and --no-interactive at the *start*.
109
+ * - Also appends a trailing --no-interactive after rawArgs as defense in
110
+ * depth: yargs lets a later `--interactive` override an earlier
111
+ * `--no-interactive`, so we ensure --no-interactive is always the last
112
+ * word on the global option.
113
+ * - Refuses to run if rawArgs contains a known interactive-toggle token.
56
114
  * - Does not inject --quiet (we want stderr diagnostics).
57
115
  */
58
116
  export async function runGt(
@@ -60,16 +118,32 @@ export async function runGt(
60
118
  opts: GtRunOptions,
61
119
  ): Promise<GtRunResult> {
62
120
  const cwd = resolvePath(opts.cwd);
121
+ for (const tok of rawArgs) {
122
+ // Match both `--interactive` and `--interactive=...` forms.
123
+ const head = tok.split("=", 1)[0];
124
+ if (FORBIDDEN_RAW_TOKENS.has(head)) {
125
+ throw new Error(
126
+ `runGt: refused to pass forbidden token ${JSON.stringify(tok)} to gt. ` +
127
+ `Interactive flows are disabled in this extension.`,
128
+ );
129
+ }
130
+ }
63
131
  const args = ["--cwd", cwd, "--no-interactive"];
64
132
  args.push(...rawArgs);
133
+ // Trailing --no-interactive wins against any later `--interactive` that
134
+ // might still slip in via an unaudited code path.
135
+ args.push("--no-interactive");
65
136
 
66
137
  return new Promise<GtRunResult>((resolve) => {
67
138
  let child: ChildProcessByStdio<null, Readable, Readable>;
68
139
  try {
69
140
  child = spawn("gt", args, {
70
141
  cwd,
71
- env: { ...process.env, ...(opts.env ?? {}) },
142
+ // Force any editor/pager/browser invocation to no-op instead of
143
+ // hanging. Safety vars override opts.env by design.
144
+ env: safeNoninteractiveEnv(opts.env),
72
145
  stdio: ["ignore", "pipe", "pipe"],
146
+ detached: process.platform !== "win32",
73
147
  });
74
148
  } catch (e) {
75
149
  resolve({
@@ -88,6 +162,16 @@ export async function runGt(
88
162
  let stdout = "";
89
163
  let stderr = "";
90
164
  let killed = false;
165
+ let settled = false;
166
+
167
+ const killChild = () => {
168
+ killed = true;
169
+ killProcessGroup(child, "SIGTERM");
170
+ setTimeout(() => killProcessGroup(child, "SIGKILL"), 1500).unref?.();
171
+ };
172
+
173
+ const timeout = setTimeout(killChild, opts.timeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS);
174
+ timeout.unref?.();
91
175
 
92
176
  child.stdout?.on("data", (d) => {
93
177
  stdout += d.toString();
@@ -98,20 +182,14 @@ export async function runGt(
98
182
  if (stderr.length > MAX_BYTES * 4) stderr = stderr.slice(-MAX_BYTES * 2);
99
183
  });
100
184
 
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
- };
185
+ const onAbort = killChild;
112
186
  opts.signal?.addEventListener("abort", onAbort, { once: true });
113
187
 
114
188
  child.on("error", (err) => {
189
+ if (settled) return;
190
+ settled = true;
191
+ clearTimeout(timeout);
192
+ opts.signal?.removeEventListener("abort", onAbort);
115
193
  resolve({
116
194
  command: "gt",
117
195
  args,
@@ -119,12 +197,15 @@ export async function runGt(
119
197
  exitCode: -1,
120
198
  stdout: sanitizeBranding(stripAnsi(stdout)),
121
199
  stderr: sanitizeBranding(stripAnsi(stderr)),
122
- timedOut: false,
200
+ timedOut: killed,
123
201
  spawnError: err.message,
124
202
  });
125
203
  });
126
204
 
127
205
  child.on("close", (code) => {
206
+ if (settled) return;
207
+ settled = true;
208
+ clearTimeout(timeout);
128
209
  opts.signal?.removeEventListener("abort", onAbort);
129
210
  resolve({
130
211
  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
 
@@ -188,28 +204,28 @@ export async function enrichFailure(cwd: string, f: FormattedResult): Promise<vo
188
204
  const b = branch ?? "<current-branch>";
189
205
  const p = trunk ?? "<trunk>";
190
206
  parts.push(
191
- `Current branch (${b}) is not tracked by Graphite. To track it, call: ` +
192
- `graphite_branch_tracking({ action: "track", branch: "${b}", parent: "${p}" }). ` +
193
- `Verify parent is correct before applying.`,
207
+ `Current branch (${b}) is not tracked by Graphite. To track it after verifying the intended parent, call: ` +
208
+ `graphite_setup({ action: "track_branch", branch: "${b}", parent: "${p}", confirmParent: true }). ` +
209
+ `Do not guess the parent if unclear; ask the user first.`,
194
210
  );
195
211
  }
196
212
  if (f.hints.notInitialized) {
197
213
  parts.push(
198
- `Graphite not initialized in this repo. Call: graphite_repo({ action: "init", trunk: "<trunk-branch>" }).`,
214
+ `Graphite not initialized in this repo. Call: graphite_setup({ action: "init_repo", trunk: "<trunk-branch>" }).`,
199
215
  );
200
216
  }
201
217
  if (f.hints.conflictHalted) {
202
218
  parts.push(
203
219
  `A Graphite command is halted by a conflict. After resolving in git, call: ` +
204
- `graphite_recovery({ action: "continue" }) (or "abort").`,
220
+ `graphite_recover({ action: "continue" }) (or "abort").`,
205
221
  );
206
222
  }
207
223
  if (f.hints.restackNeeded) {
208
- parts.push(`Stack is out of date. Call: graphite_stack_restack().`);
224
+ parts.push(`Stack is out of date. Call: graphite_sync() to sync and restack.`);
209
225
  }
210
226
  if (f.hints.trunkOutOfSync) {
211
227
  parts.push(
212
- `Trunk is out of sync with remote. Call: graphite_remote_sync({ action: "sync" }) first.`,
228
+ `Trunk is out of sync with remote. Call: graphite_sync() first.`,
213
229
  );
214
230
  }
215
231
  if (f.hints.notAuthenticated) {
@@ -221,7 +237,7 @@ export async function enrichFailure(cwd: string, f: FormattedResult): Promise<vo
221
237
  const b = f.hints.checkedOutElsewhere.branch ?? "<branch>";
222
238
  parts.push(
223
239
  `Branch ${b} is checked out in another worktree. Switch to that worktree, or use ` +
224
- `\`graphite_branch_create({ onto: "${b}", ... })\` to stack a new branch on top.`,
240
+ `switch to a different branch with graphite_navigate before mutating the stack.`,
225
241
  );
226
242
  }
227
243
  if (f.hints.invalidArgument) {
@@ -231,7 +247,7 @@ export async function enrichFailure(cwd: string, f: FormattedResult): Promise<vo
231
247
  }
232
248
  if (f.hints.operatingOnTrunk) {
233
249
  parts.push(
234
- `Operation refused on the trunk branch. Check out a non-trunk branch first (graphite_branch_navigate).`,
250
+ `Operation refused on the trunk branch. Check out a non-trunk branch first with graphite_navigate.`,
235
251
  );
236
252
  }
237
253
 
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. */
@@ -0,0 +1,140 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { runGt } from "../lib/exec";
3
+ import { assertSafeRef, flagEq } from "../lib/argv";
4
+ import { ensureSuccess, renderText } from "../lib/result";
5
+ import {
6
+ CwdParam,
7
+ StringEnum,
8
+ Type,
9
+ type ToolReturn,
10
+ } from "../lib/schema";
11
+
12
+ /**
13
+ * graphite_change — the only blessed branch-mutation path.
14
+ *
15
+ * action=create gt create -am "<message>" (new branch on top of current)
16
+ * action=amend gt modify -am "<message>" (amend current branch's commit)
17
+ * action=amend_into gt modify --into <branch> -am "<message>"
18
+ * action=absorb gt absorb (dry-run by default)
19
+ *
20
+ * Always stages all changes (matches the golden-path `-am`). No editor, no
21
+ * patch/hunk picker, no AI metadata.
22
+ */
23
+ export function registerChange(pi: ExtensionAPI) {
24
+ pi.registerTool({
25
+ name: "graphite_change",
26
+ label: "Graphite: change",
27
+ description:
28
+ "Create or amend a branch commit in the Graphite stack. action=create stacks a new branch on top of the current one. action=amend updates the current branch's commit. action=amend_into pushes staged hunks into a downstack branch. action=absorb auto-routes staged hunks to the correct commits (dry-run by default).",
29
+ promptSnippet:
30
+ "graphite_change: create | amend | amend_into | absorb — the only branch mutation tool",
31
+ promptGuidelines: [
32
+ "Use graphite_change action=create to start a new PR branch on top of the current branch. Always provide `message`.",
33
+ "Use graphite_change action=amend to update the current PR's commit. Always provide `message`.",
34
+ "Run graphite_status first to confirm you are on the intended branch.",
35
+ "graphite_change always stages all changes (matches `gt create -am` / `gt modify -am`). Stage selectively with `git add -p` outside this tool if you need a partial commit, then call graphite_change.",
36
+ ],
37
+ parameters: Type.Object({
38
+ cwd: CwdParam,
39
+ action: StringEnum([
40
+ "create",
41
+ "amend",
42
+ "amend_into",
43
+ "absorb",
44
+ ] as const),
45
+ message: Type.Optional(
46
+ Type.String({
47
+ description:
48
+ "Commit message. Required for create/amend/amend_into.",
49
+ }),
50
+ ),
51
+ name: Type.Optional(
52
+ Type.String({
53
+ description: "action=create: branch name (default generated from message).",
54
+ }),
55
+ ),
56
+ insert: Type.Optional(
57
+ Type.Boolean({
58
+ description:
59
+ "action=create: insert between current branch and its child, rebasing children (--insert).",
60
+ }),
61
+ ),
62
+ includeUntracked: Type.Optional(
63
+ Type.Boolean({
64
+ description:
65
+ "action=create|amend|amend_into: include untracked files (--update). Default false; staged + tracked-modified are always included via --all.",
66
+ }),
67
+ ),
68
+ into: Type.Optional(
69
+ Type.String({
70
+ description: "action=amend_into: target downstack branch to amend into.",
71
+ }),
72
+ ),
73
+ apply: Type.Optional(
74
+ Type.Boolean({
75
+ description:
76
+ "action=absorb: false (default) => --dry-run; true => --force (apply).",
77
+ }),
78
+ ),
79
+ }),
80
+ async execute(_id, p, signal): Promise<ToolReturn> {
81
+ let args: string[];
82
+ switch (p.action) {
83
+ case "create": {
84
+ if (!p.message) {
85
+ throw new Error("graphite_change action=create requires `message`.");
86
+ }
87
+ args = ["create"];
88
+ if (p.name) args.push(assertSafeRef(p.name, "name"));
89
+ args.push(flagEq("--message", p.message));
90
+ // `-am` semantics: always stage tracked modifications.
91
+ args.push("--all");
92
+ if (p.includeUntracked) args.push("--update");
93
+ if (p.insert) args.push("--insert");
94
+ args.push("--no-ai");
95
+ break;
96
+ }
97
+ case "amend": {
98
+ if (!p.message) {
99
+ throw new Error("graphite_change action=amend requires `message`.");
100
+ }
101
+ args = ["modify", "--all"];
102
+ if (p.includeUntracked) args.push("--update");
103
+ args.push(flagEq("--message", p.message));
104
+ break;
105
+ }
106
+ case "amend_into": {
107
+ if (!p.into) {
108
+ throw new Error("graphite_change action=amend_into requires `into`.");
109
+ }
110
+ if (!p.message) {
111
+ throw new Error(
112
+ "graphite_change action=amend_into requires `message`.",
113
+ );
114
+ }
115
+ args = ["modify", "--all"];
116
+ if (p.includeUntracked) args.push("--update");
117
+ args.push(flagEq("--into", assertSafeRef(p.into, "into")));
118
+ args.push(flagEq("--message", p.message));
119
+ break;
120
+ }
121
+ case "absorb": {
122
+ const apply = p.apply === true;
123
+ args = ["absorb"];
124
+ if (!apply) args.push("--dry-run");
125
+ else args.push("--force");
126
+ // Match `-am` style: include tracked modifications for absorb too.
127
+ args.push("--all");
128
+ break;
129
+ }
130
+ }
131
+ const label = `gt ${args.join(" ")}`;
132
+ const r = await runGt(args, { cwd: p.cwd, signal });
133
+ const f = await ensureSuccess(label, r, p.cwd);
134
+ return {
135
+ content: [{ type: "text", text: renderText(label, f) }],
136
+ details: { action: p.action, result: f },
137
+ };
138
+ },
139
+ });
140
+ }
@@ -0,0 +1,99 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { runGt } from "../lib/exec";
3
+ import { assertSafeRef, flagEq } from "../lib/argv";
4
+ import { ensureSuccess, renderText } from "../lib/result";
5
+ import {
6
+ CwdParam,
7
+ StringEnum,
8
+ Type,
9
+ type ToolReturn,
10
+ } from "../lib/schema";
11
+
12
+ /**
13
+ * graphite_navigate — move around the current stack.
14
+ *
15
+ * Stacked PRs encode "which branch you are on = which PR you will modify",
16
+ * so navigation is a core part of the workflow. Use this before
17
+ * graphite_change to make sure you are on the right branch:
18
+ * - to update an existing PR, checkout that PR's branch
19
+ * - to add a child PR, navigate to its intended parent first
20
+ * - to add a base PR, navigate to trunk
21
+ */
22
+ export function registerNavigate(pi: ExtensionAPI) {
23
+ pi.registerTool({
24
+ name: "graphite_navigate",
25
+ label: "Graphite: navigate",
26
+ description:
27
+ "Move around the current Graphite stack: checkout a specific branch, jump to trunk, or step up/down/top/bottom. Use this before graphite_change so you are mutating the right PR.",
28
+ promptSnippet:
29
+ "graphite_navigate: checkout / trunk / up / down / top / bottom in the current stack",
30
+ promptGuidelines: [
31
+ "Before any graphite_change, confirm you are on the intended branch via graphite_status or graphite_navigate.",
32
+ "To create a child PR, navigate to its intended parent first. To create a base PR, navigate to trunk.",
33
+ ],
34
+ parameters: Type.Object({
35
+ cwd: CwdParam,
36
+ action: StringEnum([
37
+ "checkout",
38
+ "trunk",
39
+ "up",
40
+ "down",
41
+ "top",
42
+ "bottom",
43
+ ] as const),
44
+ branch: Type.Optional(
45
+ Type.String({ description: "action=checkout: branch to checkout." }),
46
+ ),
47
+ steps: Type.Optional(
48
+ Type.Integer({
49
+ minimum: 1,
50
+ description: "action=up|down: step count.",
51
+ }),
52
+ ),
53
+ to: Type.Optional(
54
+ Type.String({
55
+ description:
56
+ "action=up: target descendant when the current branch has multiple children (--to).",
57
+ }),
58
+ ),
59
+ }),
60
+ async execute(_id, p, signal): Promise<ToolReturn> {
61
+ let args: string[];
62
+ switch (p.action) {
63
+ case "checkout":
64
+ if (!p.branch) {
65
+ throw new Error(
66
+ "action=checkout requires `branch` (interactive selector disabled). Use action=trunk to jump to trunk.",
67
+ );
68
+ }
69
+ args = ["checkout", assertSafeRef(p.branch, "branch")];
70
+ break;
71
+ case "trunk":
72
+ args = ["checkout", "--trunk"];
73
+ break;
74
+ case "up":
75
+ args = ["up"];
76
+ if (p.steps != null) args.push(flagEq("--steps", p.steps));
77
+ if (p.to) args.push(flagEq("--to", assertSafeRef(p.to, "to")));
78
+ break;
79
+ case "down":
80
+ args = ["down"];
81
+ if (p.steps != null) args.push(flagEq("--steps", p.steps));
82
+ break;
83
+ case "top":
84
+ args = ["top"];
85
+ break;
86
+ case "bottom":
87
+ args = ["bottom"];
88
+ break;
89
+ }
90
+ const label = `gt ${args.join(" ")}`;
91
+ const r = await runGt(args, { cwd: p.cwd, signal });
92
+ const f = await ensureSuccess(label, r, p.cwd);
93
+ return {
94
+ content: [{ type: "text", text: renderText(label, f) }],
95
+ details: { action: p.action, result: f },
96
+ };
97
+ },
98
+ });
99
+ }