pi-git-commit 1.0.6 → 1.0.8
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 -5
- package/index.ts +76 -36
- package/package.json +3 -1
package/README.md
CHANGED
|
@@ -5,8 +5,8 @@ Keeps mutative git operations out of the agent's bash and provides a safe, revie
|
|
|
5
5
|
## What you get
|
|
6
6
|
|
|
7
7
|
- **Bash git guard.** Mutative git commands are blocked in the agent's bash tool — `add`, `stage`, `commit`, `push`, `pull`, `merge`, `rebase`, `reset`, `clean`, `rm`, `restore`, `switch`, `cherry-pick`, `revert`, `mv`, `init`, `clone`, index/object plumbing (`read-tree`, `checkout-index`, `merge-file`, `prune-packed`), plus mutative forms of `branch` (including creation, `-u`, `-f`, `-c`/`-C`/`--copy`, `-D`/`-M`, `--force`, `-t`/`--track`), `tag` (including creation), `checkout` (including whole-tree restores like `checkout -- .`), `stash`, `submodule`, `worktree`, `config`, `remote`, `apply`, `notes`, `update-ref`, `gc` and more. Read-only commands (`status`, `diff`, `log`, `fetch`, `branch`, `tag`, `stash list`, ...) stay allowed.
|
|
8
|
-
- **`git_commit` tool.** The agent stages everything and commits with a `FIX` / `IMPROVE` / `NEW` type prefix. Inactive by default: `/commit` activates it for the commit flow and it is disabled again after
|
|
9
|
-
- **`/commit` command.** Waits for queued messages to finish, stages all changes, shows the staged diff, activates the `git_commit` tool, and asks the agent to review
|
|
8
|
+
- **`git_commit` tool.** The agent stages everything and commits with a `FIX` / `IMPROVE` / `NEW` type prefix. Inactive by default: `/commit` activates it for the commit flow and it is disabled again after a successful commit, so the agent cannot commit on its own at other times.
|
|
9
|
+
- **`/commit` command.** Waits for queued messages to finish, stages all changes, shows a collapsed summary of the staged diff (a `git diff --stat` line; press `ctrl+o` to expand the full diff), activates the `git_commit` tool, and asks the agent to review the changes and commit via `git_commit` — never via bash. Run `/stop-commit` at any point to abort the flow.
|
|
10
10
|
- **`/stop-commit` command.** Aborts a pending commit flow: deactivates the `git_commit` tool and cancels a `/commit` that is still waiting for queued messages, so no commit is made.
|
|
11
11
|
- **`/toggle-allow-git` command.** Temporarily allows mutative git commands in bash for the current session. The guard re-arms on the next session.
|
|
12
12
|
|
|
@@ -18,7 +18,7 @@ Keeps mutative git operations out of the agent's bash and provides a safe, revie
|
|
|
18
18
|
/commit
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
-
2. The extension stages the working tree and
|
|
21
|
+
2. The extension stages the working tree and shows a collapsed summary of the staged diff — press `ctrl+o` to expand it. The full diff is handed to the agent with instructions to review it.
|
|
22
22
|
|
|
23
23
|
3. The agent commits using the `git_commit` tool:
|
|
24
24
|
|
|
@@ -56,7 +56,7 @@ pi install /path/to/pi-git-commit
|
|
|
56
56
|
| `type` | `FIX` (bug fix), `IMPROVE` (improvement), or `NEW` (new feature). |
|
|
57
57
|
| `message` | Commit message in imperative mood, without the type prefix (it is added automatically). A leading type word matching the chosen type is stripped (with `:`, whitespace, or `-`/`—` separators, any casing, repeats included) so the type is never duplicated. Multi-line allowed for detailed changes. |
|
|
58
58
|
|
|
59
|
-
The tool runs `git add .` followed by `git commit -m "<TYPE>: <message>"` and reports staging or commit failures as tool errors. A leading type word in the message is stripped whenever it repeats the chosen type — with `:`, whitespace, or `-`/`—` separators, at any casing, repeated prefixes included — so the type never ends up duplicated: `FIX: Fix: ...`, `FIX: Fix ...`, and `fix - ...` all become `FIX: ...`. The tool is inactive by default and only becomes available when you run `/commit`; it is deactivated again after a
|
|
59
|
+
The tool runs `git add .` followed by `git commit -m "<TYPE>: <message>"` and reports staging or commit failures as tool errors. A leading type word in the message is stripped whenever it repeats the chosen type — with `:`, whitespace, or `-`/`—` separators, at any casing, repeated prefixes included — so the type never ends up duplicated: `FIX: Fix: ...`, `FIX: Fix ...`, and `fix - ...` all become `FIX: ...`. The tool is inactive by default and only becomes available when you run `/commit`; it is deactivated again after a successful commit, so the agent cannot commit at arbitrary points in the conversation. If a commit fails, the tool stays active and the agent can retry immediately without re-running `/commit`.
|
|
60
60
|
|
|
61
61
|
## The bash guard
|
|
62
62
|
|
|
@@ -67,7 +67,7 @@ The guard parses the command into segments (pipelines, `&&`, `||`, `;`, `&`, com
|
|
|
67
67
|
A blocked command returns:
|
|
68
68
|
|
|
69
69
|
```text
|
|
70
|
-
Mutative git commands are blocked.
|
|
70
|
+
Mutative git commands are blocked. Ask the user to run /toggle-allow-git to allow them for this session.
|
|
71
71
|
```
|
|
72
72
|
|
|
73
73
|
## Troubleshooting
|
package/index.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { keyHint, renderDiff, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Box, Text } from "@earendil-works/pi-tui";
|
|
2
3
|
import { Type } from "typebox";
|
|
3
4
|
|
|
4
5
|
const COMMIT_TYPES = ["FIX", "IMPROVE", "NEW"] as const;
|
|
6
|
+
const DIFF_CUSTOM_TYPE = "git-commit-diff";
|
|
5
7
|
|
|
6
8
|
export default function (pi: ExtensionAPI) {
|
|
7
9
|
let gitBlocked = true;
|
|
@@ -13,10 +15,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
13
15
|
const GIT_META_OPTS = new Set(["--help", "-h", "--version"]);
|
|
14
16
|
const GIT_OPTS_BARE = new Set(["--bare", "-p", "--paginate", "--no-pager", "--no-replace-objects", "--literal-pathspecs", "--glob-pathspecs", "--noglob-pathspecs", "--icase-pathspecs", "--no-optional-locks", "--html-path", "--man-path", "--info-path"]);
|
|
15
17
|
const GIT_OPTS_WITH_ARG = new Set(["-c", "--git-dir", "--work-tree", "--namespace", "--exec-path", "--super-prefix", "--shallow-file", "--template", "--upload-pack"]);
|
|
18
|
+
const GIT_TAG_VALUE_FLAGS = new Set(["--sort", "--format", "-v"]);
|
|
19
|
+
const GIT_TAG_LIST_FORCING = new Set(["--contains", "--no-contains", "--merged", "--no-merged", "--points-at"]);
|
|
16
20
|
|
|
17
21
|
const blockAll = () => true;
|
|
18
22
|
const hasAny = (args: string[], values: string[]) => values.some((value) => args.includes(value));
|
|
19
|
-
const
|
|
23
|
+
const blockUnlessAny = (values: string[]) => (args: string[]) => !hasAny(args, values);
|
|
20
24
|
const hasShortFlag = (args: string[], flags: string) => args.some((arg) => arg.startsWith("-") && !arg.startsWith("--") && arg.length > 1 && [...arg.slice(1)].some((flag) => flags.includes(flag)));
|
|
21
25
|
|
|
22
26
|
const GIT_RULES: Record<string, (args: string[]) => boolean> = {
|
|
@@ -62,15 +66,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
62
66
|
if (hasAny(args, ["--add", "--unset", "--unset-all", "--replace-all", "--remove-section", "--rename-section", "--edit", "-e"])) return true;
|
|
63
67
|
return !hasAny(args, ["--list", "-l", "--get", "--get-all", "--get-regexp", "--show-origin", "--show-scope"]);
|
|
64
68
|
},
|
|
65
|
-
remote: (args) =>
|
|
66
|
-
apply:
|
|
67
|
-
notes:
|
|
68
|
-
lfs:
|
|
69
|
-
"sparse-checkout":
|
|
70
|
-
stash:
|
|
71
|
-
submodule:
|
|
72
|
-
worktree:
|
|
73
|
-
reflog: (args) =>
|
|
69
|
+
remote: (args) => args.length > 0 && blockUnlessAny(["-v", "show", "get-url"])(args),
|
|
70
|
+
apply: blockUnlessAny(["--check", "--stat"]),
|
|
71
|
+
notes: blockUnlessAny(["list", "show"]),
|
|
72
|
+
lfs: blockUnlessAny(["ls-files", "status"]),
|
|
73
|
+
"sparse-checkout": blockUnlessAny(["list"]),
|
|
74
|
+
stash: blockUnlessAny(["list", "show"]),
|
|
75
|
+
submodule: blockUnlessAny(["status", "init", "summary"]),
|
|
76
|
+
worktree: blockUnlessAny(["list"]),
|
|
77
|
+
reflog: (args) => args.length > 0 && blockUnlessAny(["show"])(args),
|
|
74
78
|
branch: (args) => {
|
|
75
79
|
if (args.includes("--")) return true;
|
|
76
80
|
if (hasAny(args, ["--delete", "--move", "--copy", "--force", "--track", "--prune", "--unset-upstream", "--edit-description"]) || args.some((arg) => arg.startsWith("--set-upstream-to"))) return true;
|
|
@@ -80,9 +84,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
80
84
|
},
|
|
81
85
|
tag: (args) => {
|
|
82
86
|
if (args.length === 0) return false;
|
|
87
|
+
if (hasAny(args, ["--delete", "--annotate", "--force", "--sign", "--edit", "--cleanup"]) || hasShortFlag(args, "damfsueF")) return true;
|
|
83
88
|
const first = args[0];
|
|
84
89
|
if (first === "-l" || first === "--list" || first.startsWith("-n")) return false;
|
|
85
|
-
|
|
90
|
+
const readOnlyFlags = ["--contains", "--no-contains", "--merged", "--no-merged", "--points-at", "--sort", "--format", "--column", "--no-column", "--color", "--ignore-case", "--verbose", "-i", "-v"];
|
|
91
|
+
if (!readOnlyFlags.some((flag) => first.startsWith(flag))) return true;
|
|
92
|
+
if ([...GIT_TAG_LIST_FORCING].some((flag) => args.some((arg) => arg === flag || arg.startsWith(`${flag}=`)))) return false;
|
|
93
|
+
return args.some((arg, index) => index > 0 && !arg.startsWith("-") && !GIT_TAG_VALUE_FLAGS.has(args[index - 1]));
|
|
86
94
|
},
|
|
87
95
|
checkout: (args) => {
|
|
88
96
|
if (args[0] !== "--") return true;
|
|
@@ -96,7 +104,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
96
104
|
const maskHeredocBodies = (command: string): string => {
|
|
97
105
|
let masked = "";
|
|
98
106
|
let cursor = 0;
|
|
99
|
-
const heredocRe = /<<-?\s
|
|
107
|
+
const heredocRe = /<<-?\s*\\?['"]?([A-Za-z_][A-Za-z0-9_]*)['"]?/g;
|
|
100
108
|
let match: RegExpExecArray | null;
|
|
101
109
|
while ((match = heredocRe.exec(command)) !== null) {
|
|
102
110
|
const current = match;
|
|
@@ -159,6 +167,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
159
167
|
let subcommandIndex = -1;
|
|
160
168
|
for (let i = 0; i < tokens.length; i++) {
|
|
161
169
|
const token = tokens[i];
|
|
170
|
+
if (token === "git" || token === "git.exe") continue;
|
|
162
171
|
if (GIT_META_OPTS.has(token)) return false;
|
|
163
172
|
if (GIT_OPTS_BARE.has(token)) continue;
|
|
164
173
|
if (GIT_OPTS_WITH_ARG.has(token)) {
|
|
@@ -203,7 +212,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
203
212
|
if (typeof command !== "string") return undefined;
|
|
204
213
|
const trimmed = command.trim();
|
|
205
214
|
if (gitBlocked && containsBlockedGitCommand(trimmed)) {
|
|
206
|
-
return { block: true, reason: "Mutative git commands are blocked.
|
|
215
|
+
return { block: true, reason: "Mutative git commands are blocked. Ask the user to run /toggle-allow-git to allow them for this session." };
|
|
207
216
|
}
|
|
208
217
|
return undefined;
|
|
209
218
|
});
|
|
@@ -233,6 +242,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
233
242
|
return rest;
|
|
234
243
|
};
|
|
235
244
|
|
|
245
|
+
const toolError = (text: string) => ({ content: [{ type: "text" as const, text }], details: {}, isError: true });
|
|
246
|
+
|
|
236
247
|
pi.registerTool({
|
|
237
248
|
name: "git_commit",
|
|
238
249
|
label: "Git Commit",
|
|
@@ -250,31 +261,34 @@ export default function (pi: ExtensionAPI) {
|
|
|
250
261
|
}),
|
|
251
262
|
}),
|
|
252
263
|
async execute(_toolCallId, params, signal, _onUpdate, _ctx) {
|
|
264
|
+
let committed = false;
|
|
253
265
|
try {
|
|
254
266
|
const { type, message } = params;
|
|
255
267
|
const description = stripLeadingCommitType(type, message);
|
|
256
268
|
if (!description) {
|
|
257
|
-
return
|
|
269
|
+
return toolError("Commit message must not be empty.");
|
|
258
270
|
}
|
|
259
271
|
const fullMessage = `${type}: ${description}`;
|
|
260
272
|
const addResult = await pi.exec("git", ["add", "."], { signal });
|
|
261
273
|
if (addResult.code !== 0) {
|
|
262
|
-
return
|
|
274
|
+
return toolError(`Staging failed: ${addResult.stderr}`);
|
|
263
275
|
}
|
|
264
276
|
|
|
265
277
|
const result = await pi.exec("git", ["commit", "-m", fullMessage], { signal });
|
|
266
278
|
if (result.code !== 0) {
|
|
267
|
-
return
|
|
279
|
+
return toolError(`Commit failed: ${result.stderr}`);
|
|
268
280
|
}
|
|
269
|
-
|
|
281
|
+
committed = true;
|
|
270
282
|
pi.sendMessage(
|
|
271
|
-
{ customType: "git-commit-
|
|
283
|
+
{ customType: "git-commit-flow-complete", content: "The commit flow is complete.", display: false },
|
|
272
284
|
{ deliverAs: "steer" },
|
|
273
285
|
);
|
|
274
286
|
return { content: [{ type: "text", text: `✓ Committed: ${fullMessage}` }], details: {} };
|
|
275
287
|
} finally {
|
|
276
|
-
|
|
277
|
-
|
|
288
|
+
if (committed) {
|
|
289
|
+
commitFlowActive = false;
|
|
290
|
+
deactivateGitCommit();
|
|
291
|
+
}
|
|
278
292
|
}
|
|
279
293
|
},
|
|
280
294
|
});
|
|
@@ -285,6 +299,23 @@ export default function (pi: ExtensionAPI) {
|
|
|
285
299
|
deactivateGitCommit();
|
|
286
300
|
});
|
|
287
301
|
|
|
302
|
+
pi.registerMessageRenderer(DIFF_CUSTOM_TYPE, (message, { expanded, outputPad }, theme) => {
|
|
303
|
+
const details = message.details as { diff?: string; stat?: string } | undefined;
|
|
304
|
+
const box = new Box(outputPad, 1, (text) => theme.bg("customMessageBg", text));
|
|
305
|
+
box.addChild(new Text(theme.fg("accent", "Staged changes"), 0, 0));
|
|
306
|
+
if (expanded) {
|
|
307
|
+
if (details?.diff) {
|
|
308
|
+
box.addChild(new Text(renderDiff(details.diff), 0, 0));
|
|
309
|
+
}
|
|
310
|
+
} else {
|
|
311
|
+
if (details?.stat) {
|
|
312
|
+
box.addChild(new Text(details.stat, 0, 0));
|
|
313
|
+
}
|
|
314
|
+
box.addChild(new Text(theme.fg("dim", `(${keyHint("app.tools.expand", "to expand")})`), 0, 0));
|
|
315
|
+
}
|
|
316
|
+
return box;
|
|
317
|
+
});
|
|
318
|
+
|
|
288
319
|
pi.registerCommand("commit", {
|
|
289
320
|
description: "Stage files and show diff for commit",
|
|
290
321
|
handler: async (_args, ctx) => {
|
|
@@ -294,26 +325,28 @@ export default function (pi: ExtensionAPI) {
|
|
|
294
325
|
}
|
|
295
326
|
commitFlowActive = true;
|
|
296
327
|
|
|
328
|
+
const execGit = async (label: string, args: string[]) => {
|
|
329
|
+
const result = await pi.exec("git", args);
|
|
330
|
+
if (result.code !== 0) {
|
|
331
|
+
ctx.ui.notify(`${label} failed: ${result.stderr}`, "error");
|
|
332
|
+
commitFlowActive = false;
|
|
333
|
+
return undefined;
|
|
334
|
+
}
|
|
335
|
+
return result;
|
|
336
|
+
};
|
|
337
|
+
|
|
297
338
|
try {
|
|
298
339
|
await ctx.ui.setWorkingMessage("Waiting for queued messages to complete...");
|
|
299
340
|
await ctx.waitForIdle();
|
|
300
341
|
if (!commitFlowActive) return;
|
|
301
342
|
|
|
302
343
|
await ctx.ui.setWorkingMessage("Staging files...");
|
|
303
|
-
const addResult = await
|
|
304
|
-
if (addResult
|
|
305
|
-
ctx.ui.notify(`git add failed: ${addResult.stderr}`, "error");
|
|
306
|
-
commitFlowActive = false;
|
|
307
|
-
return;
|
|
308
|
-
}
|
|
344
|
+
const addResult = await execGit("git add", ["add", "."]);
|
|
345
|
+
if (!addResult || !commitFlowActive) return;
|
|
309
346
|
|
|
310
347
|
await ctx.ui.setWorkingMessage("Getting diff...");
|
|
311
|
-
const diffResult = await
|
|
312
|
-
if (diffResult
|
|
313
|
-
ctx.ui.notify(`git diff failed: ${diffResult.stderr}`, "error");
|
|
314
|
-
commitFlowActive = false;
|
|
315
|
-
return;
|
|
316
|
-
}
|
|
348
|
+
const diffResult = await execGit("git diff", ["diff", "--staged"]);
|
|
349
|
+
if (!diffResult || !commitFlowActive) return;
|
|
317
350
|
|
|
318
351
|
if (!diffResult.stdout.trim()) {
|
|
319
352
|
ctx.ui.notify("Nothing to commit (empty diff). Stage files first.", "warning");
|
|
@@ -321,12 +354,19 @@ export default function (pi: ExtensionAPI) {
|
|
|
321
354
|
return;
|
|
322
355
|
}
|
|
323
356
|
|
|
324
|
-
const diff = diffResult.stdout
|
|
357
|
+
const diff = diffResult.stdout;
|
|
358
|
+
|
|
359
|
+
const statResult = await pi.exec("git", ["diff", "--staged", "--stat"]);
|
|
360
|
+
if (!commitFlowActive) return;
|
|
361
|
+
const stat = statResult.code === 0 ? statResult.stdout.trim().split("\n").pop() ?? "" : "";
|
|
325
362
|
|
|
326
363
|
const prompt = `DO NOT use bash for git. Use ONLY the \`git_commit\` tool.\n\nReview staged changes:\n\`\`\`diff\n${diff}\`\`\`\n\nUse \`git_commit\` tool with:\n- type: FIX, IMPROVE, or NEW\n- message: brief description (imperative mood)`;
|
|
327
364
|
if (!commitFlowActive) return;
|
|
328
365
|
activateGitCommit();
|
|
329
|
-
pi.
|
|
366
|
+
pi.sendMessage(
|
|
367
|
+
{ customType: DIFF_CUSTOM_TYPE, content: prompt, display: true, details: { diff, stat } },
|
|
368
|
+
{ deliverAs: "followUp", triggerTurn: true },
|
|
369
|
+
);
|
|
330
370
|
} finally {
|
|
331
371
|
ctx.ui.setWorkingMessage();
|
|
332
372
|
}
|
|
@@ -346,7 +386,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
346
386
|
deactivateGitCommit();
|
|
347
387
|
if (toolWasActive) {
|
|
348
388
|
pi.sendMessage(
|
|
349
|
-
{ customType: "git-commit-stopped", content: "The user stopped the commit flow with /stop-commit. Do not attempt to commit.
|
|
389
|
+
{ customType: "git-commit-stopped", content: "The user stopped the commit flow with /stop-commit. Do not attempt to commit.", display: false },
|
|
350
390
|
{ deliverAs: "steer" },
|
|
351
391
|
);
|
|
352
392
|
ctx.ui.notify("Commit flow stopped. The git_commit tool is deactivated.", "info");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-git-commit",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.8",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Pi extension: block mutative git commands in bash and provide a git_commit tool plus /commit, /stop-commit and /toggle-allow-git commands",
|
|
6
6
|
"main": "index.ts",
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
},
|
|
32
32
|
"peerDependencies": {
|
|
33
33
|
"@earendil-works/pi-coding-agent": "*",
|
|
34
|
+
"@earendil-works/pi-tui": "*",
|
|
34
35
|
"typebox": "*"
|
|
35
36
|
},
|
|
36
37
|
"engines": {
|
|
@@ -42,6 +43,7 @@
|
|
|
42
43
|
},
|
|
43
44
|
"devDependencies": {
|
|
44
45
|
"@earendil-works/pi-coding-agent": "^0.84.0",
|
|
46
|
+
"@earendil-works/pi-tui": "^0.84.1",
|
|
45
47
|
"@types/node": "^24.0.0",
|
|
46
48
|
"typescript": "~5.9.3",
|
|
47
49
|
"vitest": "^4.1.9",
|