pi-git-commit 1.0.2 → 1.0.4
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 +57 -24
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,9 +4,9 @@ Keeps mutative git operations out of the agent's bash and provides a safe, revie
|
|
|
4
4
|
|
|
5
5
|
## What you get
|
|
6
6
|
|
|
7
|
-
- **Bash git guard.** Mutative git commands are blocked in the agent's bash tool — `add`, `commit`, `push`, `pull`, `merge`, `rebase`, `reset`, `clean`, `rm`, `restore`, `switch`, `cherry-pick`, `revert`, `mv`, `init`, `clone`, plus mutative forms of `branch`, `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.
|
|
9
|
-
- **`/commit` command.** Waits for queued messages to finish, stages all changes, shows the staged diff, and asks the agent to review it and commit via `git_commit` — never via bash.
|
|
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 use, so the agent cannot commit on its own at other times.
|
|
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 it and commit via `git_commit` — never via bash.
|
|
10
10
|
- **`/toggle-allow-git` command.** Temporarily allows mutative git commands in bash for the current session. The guard re-arms on the next session.
|
|
11
11
|
|
|
12
12
|
## Quick start
|
|
@@ -53,13 +53,13 @@ pi install /path/to/pi-git-commit
|
|
|
53
53
|
| `type` | `FIX` (bug fix), `IMPROVE` (improvement), or `NEW` (new feature). |
|
|
54
54
|
| `message` | Commit message in imperative mood. Multi-line allowed for detailed changes. |
|
|
55
55
|
|
|
56
|
-
The tool runs `git add .` followed by `git commit -m "<TYPE>: <message>"` and reports staging or commit failures as tool errors. The
|
|
56
|
+
The tool runs `git add .` followed by `git commit -m "<TYPE>: <message>"` and reports staging or commit failures as tool errors. The tool is inactive by default and only becomes available when you run `/commit`; it is deactivated again after a single use (success or failure), so the agent cannot commit at arbitrary points in the conversation. If a commit fails, run `/commit` again to retry.
|
|
57
57
|
|
|
58
58
|
## The bash guard
|
|
59
59
|
|
|
60
60
|
The guard intercepts `tool_call` events for the bash tool and blocks commands that match mutative git forms. The block list is a conservative superset: anything that can change repository state is blocked, while a curated set of read-only forms is explicitly allowed (for example `git fetch`, `git stash list`, `git remote -v`, `git config --get`, `git apply --check`, `git checkout -- <file>`, `git submodule status`, `git worktree list`).
|
|
61
61
|
|
|
62
|
-
The guard parses the command into segments (pipelines, `&&`, `||`, `;`, `&`, command and process substitution, newlines) and inspects only segments that actually invoke `git` — including path-qualified invocations (`/usr/bin/git`), wrapper prefixes with their flags (`sudo -u root`, `nice -n 5`, `timeout 5`), environment-assignment prefixes (`VAR=1 git ...`, `env VAR=1 git ...`), control constructs (`{ ...; }`, `!`, `if`, `while`), and `sh -c`/`su -c` wrappers — while skipping git's global options such as `-C`, `-c`, `--git-dir`, and `--work-tree`. Git commands mentioned inside strings or heredocs are not blocked. Plain `git fetch` stays allowed, but `git fetch --prune`/`-p`/`--prune-tags` is blocked. Indirect invocation (aliases, variables, `find -exec`) cannot be detected reliably and is best-effort; likewise a directory passed to `git checkout --` without a trailing slash is indistinguishable from a file, so `git checkout -- src` (restoring the whole `src` tree) is not caught.
|
|
62
|
+
The guard parses the command into segments (pipelines, `&&`, `||`, `;`, `&`, command and process substitution, newlines) and inspects only segments that actually invoke `git` — including path-qualified invocations (`/usr/bin/git`), wrapper prefixes with their flags (`sudo -u root`, `nice -n 5`, `timeout 5`), environment-assignment prefixes (`VAR=1 git ...`, `env VAR=1 git ...`), control constructs (`{ ...; }`, `!`, `if`, `while`), and `sh -c`/`su -c` wrappers — while skipping git's global options such as `-C`, `-c`, `--git-dir`, and `--work-tree`. Commands nested more than four wrapper levels deep are blocked outright (fail closed), even when no git command is visible. Git commands mentioned inside strings or heredocs are not blocked. Plain `git fetch` stays allowed, but `git fetch --prune`/`-p`/`--prune-tags` is blocked. Indirect invocation (aliases, variables, `find -exec`) cannot be detected reliably and is best-effort; likewise a directory passed to `git checkout --` without a trailing slash is indistinguishable from a file, so `git checkout -- src` (restoring the whole `src` tree) is not caught.
|
|
63
63
|
|
|
64
64
|
A blocked command returns:
|
|
65
65
|
|
package/index.ts
CHANGED
|
@@ -16,9 +16,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
16
16
|
const blockAll = () => true;
|
|
17
17
|
const hasAny = (args: string[], values: string[]) => values.some((value) => args.includes(value));
|
|
18
18
|
const allowOnly = (values: string[]) => (args: string[]) => !hasAny(args, values);
|
|
19
|
+
const hasShortFlag = (args: string[], flags: string) => args.some((arg) => arg.startsWith("-") && !arg.startsWith("--") && arg.length > 1 && [...arg.slice(1)].some((flag) => flags.includes(flag)));
|
|
19
20
|
|
|
20
21
|
const GIT_RULES: Record<string, (args: string[]) => boolean> = {
|
|
21
22
|
add: blockAll,
|
|
23
|
+
stage: blockAll,
|
|
22
24
|
commit: blockAll,
|
|
23
25
|
push: blockAll,
|
|
24
26
|
pull: blockAll,
|
|
@@ -39,6 +41,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
39
41
|
"update-ref": blockAll,
|
|
40
42
|
"symbolic-ref": blockAll,
|
|
41
43
|
"update-index": blockAll,
|
|
44
|
+
"read-tree": blockAll,
|
|
45
|
+
"checkout-index": blockAll,
|
|
46
|
+
"merge-file": blockAll,
|
|
47
|
+
"prune-packed": blockAll,
|
|
42
48
|
gc: blockAll,
|
|
43
49
|
maintenance: blockAll,
|
|
44
50
|
"filter-branch": blockAll,
|
|
@@ -64,7 +70,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
64
70
|
submodule: allowOnly(["status", "init", "summary"]),
|
|
65
71
|
worktree: allowOnly(["list"]),
|
|
66
72
|
reflog: (args) => !(args.length === 0 || hasAny(args, ["show"])),
|
|
67
|
-
branch: (args) =>
|
|
73
|
+
branch: (args) => {
|
|
74
|
+
if (args.includes("--")) return true;
|
|
75
|
+
if (hasAny(args, ["--delete", "--move", "--copy", "--force", "--track", "--prune", "--unset-upstream", "--edit-description"]) || args.some((arg) => arg.startsWith("--set-upstream-to"))) return true;
|
|
76
|
+
if (hasShortFlag(args, "dmufcCDMt")) return true;
|
|
77
|
+
if (!args.some((arg) => !arg.startsWith("-"))) return false;
|
|
78
|
+
return !(hasAny(args, ["--list", "--merged", "--no-merged", "--contains", "--no-contains", "--points-at", "--show-current"]) || hasShortFlag(args, "lar"));
|
|
79
|
+
},
|
|
68
80
|
tag: (args) => {
|
|
69
81
|
if (args.length === 0) return false;
|
|
70
82
|
const first = args[0];
|
|
@@ -115,7 +127,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
115
127
|
|
|
116
128
|
const stripPrefixes = (segment: string): string => {
|
|
117
129
|
let rest = segment;
|
|
118
|
-
for (
|
|
130
|
+
for (;;) {
|
|
119
131
|
rest = stripEnvAssignments(rest);
|
|
120
132
|
if (!rest) break;
|
|
121
133
|
const match = rest.match(/^([A-Za-z_][A-Za-z0-9_]*)\b(?:\s|$)/);
|
|
@@ -164,7 +176,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
164
176
|
};
|
|
165
177
|
|
|
166
178
|
const containsBlockedGitCommand = (command: string, depth = 0): boolean => {
|
|
167
|
-
if (depth > 4) return
|
|
179
|
+
if (depth > 4) return true;
|
|
168
180
|
const masked = maskHeredocBodies(command);
|
|
169
181
|
return masked.split(/\n|;|\|\||&&|\||&|`|\$\(|<\(|>\(/).some((segment) => {
|
|
170
182
|
let rest = stripSurrounding(segment.trim());
|
|
@@ -186,13 +198,29 @@ export default function (pi: ExtensionAPI) {
|
|
|
186
198
|
|
|
187
199
|
pi.on("tool_call", async (event) => {
|
|
188
200
|
if (event.toolName !== "bash") return undefined;
|
|
189
|
-
const command =
|
|
190
|
-
if (
|
|
201
|
+
const command = event.input.command;
|
|
202
|
+
if (typeof command !== "string") return undefined;
|
|
203
|
+
const trimmed = command.trim();
|
|
204
|
+
if (gitBlocked && containsBlockedGitCommand(trimmed)) {
|
|
191
205
|
return { block: true, reason: "Mutative git commands are blocked. Use /toggle-allow-git to allow for this session." };
|
|
192
206
|
}
|
|
193
207
|
return undefined;
|
|
194
208
|
});
|
|
195
209
|
|
|
210
|
+
const activateGitCommit = () => {
|
|
211
|
+
const activeTools = pi.getActiveTools();
|
|
212
|
+
if (!activeTools.includes("git_commit")) {
|
|
213
|
+
pi.setActiveTools([...activeTools, "git_commit"]);
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const deactivateGitCommit = () => {
|
|
218
|
+
const activeTools = pi.getActiveTools();
|
|
219
|
+
if (activeTools.includes("git_commit")) {
|
|
220
|
+
pi.setActiveTools(activeTools.filter((tool) => tool !== "git_commit"));
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
|
|
196
224
|
pi.registerTool({
|
|
197
225
|
name: "git_commit",
|
|
198
226
|
label: "Git Commit",
|
|
@@ -210,33 +238,37 @@ export default function (pi: ExtensionAPI) {
|
|
|
210
238
|
}),
|
|
211
239
|
}),
|
|
212
240
|
async execute(_toolCallId, params, signal, _onUpdate, _ctx) {
|
|
241
|
+
try {
|
|
242
|
+
const { type, message } = params;
|
|
243
|
+
const trimmedMessage = message.trim();
|
|
244
|
+
if (!trimmedMessage) {
|
|
245
|
+
return { content: [{ type: "text", text: "Commit message must not be empty." }], details: {}, isError: true };
|
|
246
|
+
}
|
|
247
|
+
const fullMessage = `${type}: ${trimmedMessage}`;
|
|
248
|
+
const addResult = await pi.exec("git", ["add", "."], { signal });
|
|
249
|
+
if (addResult.code !== 0) {
|
|
250
|
+
return { content: [{ type: "text", text: `Staging failed: ${addResult.stderr}` }], details: {}, isError: true };
|
|
251
|
+
}
|
|
213
252
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
}
|
|
219
|
-
const fullMessage = `${type}: ${trimmedMessage}`;
|
|
220
|
-
const addResult = await pi.exec("git", ["add", "."], { signal });
|
|
221
|
-
if (addResult.code !== 0) {
|
|
222
|
-
return { content: [{ type: "text", text: `Staging failed: ${addResult.stderr}` }], details: {}, isError: true };
|
|
223
|
-
}
|
|
253
|
+
const result = await pi.exec("git", ["commit", "-m", fullMessage], { signal });
|
|
254
|
+
if (result.code !== 0) {
|
|
255
|
+
return { content: [{ type: "text", text: `Commit failed: ${result.stderr}` }], details: {}, isError: true };
|
|
256
|
+
}
|
|
224
257
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
258
|
+
pi.sendMessage(
|
|
259
|
+
{ customType: "git-commit-deactivated", content: "The git_commit tool is now deactivated. It cannot be used again until the user runs /commit to re-enable it.", display: false },
|
|
260
|
+
{ deliverAs: "steer" },
|
|
261
|
+
);
|
|
262
|
+
return { content: [{ type: "text", text: `✓ Committed: ${fullMessage}` }], details: {} };
|
|
263
|
+
} finally {
|
|
264
|
+
deactivateGitCommit();
|
|
228
265
|
}
|
|
229
|
-
|
|
230
|
-
return { content: [{ type: "text", text: `✓ Committed: ${fullMessage}` }], details: {} };
|
|
231
266
|
},
|
|
232
267
|
});
|
|
233
268
|
|
|
234
269
|
pi.on("session_start", () => {
|
|
235
270
|
gitBlocked = true;
|
|
236
|
-
|
|
237
|
-
if (!activeTools.includes("git_commit")) {
|
|
238
|
-
pi.setActiveTools([...activeTools, "git_commit"]);
|
|
239
|
-
}
|
|
271
|
+
deactivateGitCommit();
|
|
240
272
|
});
|
|
241
273
|
|
|
242
274
|
pi.registerCommand("commit", {
|
|
@@ -273,6 +305,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
273
305
|
const diff = diffResult.stdout || "(no changes staged)";
|
|
274
306
|
|
|
275
307
|
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 (bug fix), IMPROVE (improvement), or NEW (new feature)\n- message: brief description (imperative mood). Multi-line allowed for detailed changes.`;
|
|
308
|
+
activateGitCommit();
|
|
276
309
|
pi.sendUserMessage(prompt, { deliverAs: "followUp" });
|
|
277
310
|
} finally {
|
|
278
311
|
ctx.ui.setWorkingMessage();
|
package/package.json
CHANGED