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 +5 -2
- package/package.json +1 -1
- package/src/index.ts +4 -0
- package/src/lib/exec.ts +63 -14
- package/src/lib/result.ts +20 -4
- package/src/lib/schema.ts +5 -4
- package/src/tools/branch.ts +17 -13
- package/src/tools/pr.ts +90 -17
- package/src/tools/recovery.ts +83 -2
- package/src/tools/repo.ts +4 -12
- package/src/tools/stack.ts +402 -15
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 | `
|
|
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
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
|
-
|
|
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:
|
|
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
|
|
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, {
|
|
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", () =>
|
|
153
|
-
|
|
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
|
|
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. */
|
package/src/tools/branch.ts
CHANGED
|
@@ -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
|
-
|
|
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: "
|
|
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: "
|
|
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
|
|
251
|
+
args.push(...stageArgs((p.stage ?? "none") as StageModeValue));
|
|
246
252
|
if (p.message) args.push("--message", p.message);
|
|
247
|
-
|
|
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
|
|
254
|
-
throw new Error("action=new_commit requires `message`
|
|
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
|
|
258
|
-
|
|
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
|
|
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
|
-
|
|
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 =>
|
|
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"
|
|
124
|
+
StringEnum(["none", "cli"] as const, {
|
|
57
125
|
description:
|
|
58
|
-
"none (default) => --no-edit; cli => --edit --cli
|
|
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: "
|
|
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:
|
|
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:
|
|
248
|
+
"graphite_pr_lifecycle: view_url | merge | unlink for a PR/branch",
|
|
181
249
|
parameters: Type.Object({
|
|
182
250
|
cwd: CwdParam,
|
|
183
|
-
action: StringEnum(["
|
|
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: "
|
|
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: "
|
|
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 === "
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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)
|
|
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);
|
package/src/tools/recovery.ts
CHANGED
|
@@ -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 (
|
|
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
|
-
"
|
|
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
|
|
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
|
-
|
|
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
|
package/src/tools/stack.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,
|
|
@@ -27,9 +29,9 @@ export function registerStackView(pi: ExtensionAPI) {
|
|
|
27
29
|
}),
|
|
28
30
|
),
|
|
29
31
|
scope: Type.Optional(
|
|
30
|
-
StringEnum(["
|
|
32
|
+
StringEnum(["current_stack", "default"] as const, {
|
|
31
33
|
description:
|
|
32
|
-
"
|
|
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
|
+
}
|