pi-git-commit 1.0.1 → 1.0.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.
Files changed (3) hide show
  1. package/README.md +6 -4
  2. package/index.ts +220 -51
  3. package/package.json +1 -2
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 destructive forms of `branch`, `tag`, `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. Enabled automatically on session start.
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,12 +53,14 @@ 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 agent is instructed to only use it after you run `/commit`.
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`. 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
+
62
64
  A blocked command returns:
63
65
 
64
66
  ```text
package/index.ts CHANGED
@@ -6,58 +6,221 @@ const COMMIT_TYPES = ["FIX", "IMPROVE", "NEW"] as const;
6
6
  export default function (pi: ExtensionAPI) {
7
7
  let gitBlocked = true;
8
8
 
9
- const containsBlockedGitCommand = (command: string): boolean => {
10
- const alwaysBlocked = /\bgit\s+(add|commit|push|pull|merge|rebase|reset|clean|rm|restore|switch|cherry-pick|revert|mv|init|clone)\b/;
11
- if (alwaysBlocked.test(command)) return true;
12
-
13
- const readOnlyForms = [
14
- /\bgit\s+fetch\b/,
15
- /\bgit\s+stash\s+(list|show)\b/,
16
- /\bgit\s+remote\s*$/,
17
- /\bgit\s+config\s+(--list|-l|--get|--get-all|--get-regexp|--show-origin|--show-scope)\b/,
18
- /\bgit\s+remote\s+(-v|show|get-url)\b/,
19
- /\bgit\s+apply\s+--(check|stat)\b/,
20
- /\bgit\s+notes\s+(list|show)\b/,
21
- /\bgit\s+lfs\s+(ls-files|status)\b/,
22
- /\bgit\s+sparse-checkout\s+list\b/,
23
- ];
24
- if (readOnlyForms.some((re) => re.test(command))) return false;
25
-
26
- if (/\bgit\s+(config|remote|apply|am|notes|replace|update-ref|symbolic-ref|update-index|gc|maintenance|sparse-checkout|lfs)\b/.test(command)) return true;
27
-
28
- if (/\bgit\s+branch\b/.test(command)) {
29
- if (/\bgit\s+branch\s+(-d|-D|-m|-M|--delete|--move)\b/.test(command)) return true;
30
- return false;
31
- }
9
+ const PREFIXES = new Set(["sudo", "env", "command", "nohup", "nice", "time", "exec", "builtin", "doas", "eval", "timeout", "runuser", "pkexec"]);
10
+ const CONTROL_KEYWORDS = new Set(["if", "then", "else", "elif", "while", "until", "do", "case", "select"]);
11
+
12
+ const GIT_META_OPTS = new Set(["--help", "-h", "--version"]);
13
+ 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"]);
14
+ const GIT_OPTS_WITH_ARG = new Set(["-c", "--git-dir", "--work-tree", "--namespace", "--exec-path", "--super-prefix", "--shallow-file", "--template", "--upload-pack"]);
15
+
16
+ const blockAll = () => true;
17
+ const hasAny = (args: string[], values: string[]) => values.some((value) => args.includes(value));
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)));
20
+
21
+ const GIT_RULES: Record<string, (args: string[]) => boolean> = {
22
+ add: blockAll,
23
+ stage: blockAll,
24
+ commit: blockAll,
25
+ push: blockAll,
26
+ pull: blockAll,
27
+ merge: blockAll,
28
+ rebase: blockAll,
29
+ reset: blockAll,
30
+ clean: blockAll,
31
+ rm: blockAll,
32
+ restore: blockAll,
33
+ switch: blockAll,
34
+ "cherry-pick": blockAll,
35
+ revert: blockAll,
36
+ mv: blockAll,
37
+ init: blockAll,
38
+ clone: blockAll,
39
+ am: blockAll,
40
+ replace: blockAll,
41
+ "update-ref": blockAll,
42
+ "symbolic-ref": blockAll,
43
+ "update-index": blockAll,
44
+ "read-tree": blockAll,
45
+ "checkout-index": blockAll,
46
+ "merge-file": blockAll,
47
+ "prune-packed": blockAll,
48
+ gc: blockAll,
49
+ maintenance: blockAll,
50
+ "filter-branch": blockAll,
51
+ "filter-repo": blockAll,
52
+ "fast-import": blockAll,
53
+ prune: blockAll,
54
+ repack: blockAll,
55
+ "pack-refs": blockAll,
56
+ mergetool: blockAll,
57
+ bisect: blockAll,
58
+ subtree: blockAll,
59
+ fetch: (args) => hasAny(args, ["--prune", "-p", "-P", "--prune-tags"]),
60
+ config: (args) => {
61
+ if (hasAny(args, ["--add", "--unset", "--unset-all", "--replace-all", "--remove-section", "--rename-section", "--edit", "-e"])) return true;
62
+ return !hasAny(args, ["--list", "-l", "--get", "--get-all", "--get-regexp", "--show-origin", "--show-scope"]);
63
+ },
64
+ remote: (args) => !(args.length === 0 || args.includes("-v") || hasAny(args, ["show", "get-url"])),
65
+ apply: allowOnly(["--check", "--stat"]),
66
+ notes: allowOnly(["list", "show"]),
67
+ lfs: allowOnly(["ls-files", "status"]),
68
+ "sparse-checkout": allowOnly(["list"]),
69
+ stash: allowOnly(["list", "show"]),
70
+ submodule: allowOnly(["status", "init", "summary"]),
71
+ worktree: allowOnly(["list"]),
72
+ reflog: (args) => !(args.length === 0 || hasAny(args, ["show"])),
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
+ },
80
+ tag: (args) => {
81
+ if (args.length === 0) return false;
82
+ const first = args[0];
83
+ if (first === "-l" || first === "--list" || first.startsWith("-n")) return false;
84
+ return !["--contains", "--merged", "--no-merged", "--points-at", "--sort", "--format", "--column", "--no-column", "--color", "--ignore-case", "--verbose", "-v"].some((flag) => first.startsWith(flag));
85
+ },
86
+ checkout: (args) => {
87
+ if (args[0] !== "--") return true;
88
+ const path = args[1];
89
+ if (path === undefined) return true;
90
+ if (args.length > 2) return true;
91
+ return path === "." || path === ".." || path.endsWith("/");
92
+ },
93
+ };
32
94
 
33
- if (/\bgit\s+tag\b/.test(command)) {
34
- if (/\bgit\s+tag\s+(-d|-a|-s|-f|--delete|--annotate|--sign|--force)\b/.test(command)) return true;
35
- return false;
95
+ const maskHeredocBodies = (command: string): string => {
96
+ let masked = "";
97
+ let cursor = 0;
98
+ const heredocRe = /<<-?\s*['"]?([A-Za-z_][A-Za-z0-9_]*)['"]?/g;
99
+ let match: RegExpExecArray | null;
100
+ while ((match = heredocRe.exec(command)) !== null) {
101
+ const current = match;
102
+ if (current.index < cursor) continue;
103
+ const tail = command.slice(current.index + current[0].length);
104
+ const lines = tail.split("\n");
105
+ const end = lines.findIndex((line) => line.trim() === current[1]);
106
+ if (end === -1) continue;
107
+ const body = lines.slice(0, end + 1);
108
+ masked += command.slice(cursor, current.index) + current[0] + body.map((line) => " ".repeat(line.length)).join("\n");
109
+ cursor = current.index + current[0].length + body.join("\n").length;
36
110
  }
111
+ return masked + command.slice(cursor);
112
+ };
37
113
 
38
- if (/\bgit\s+checkout\s+--\s/.test(command)) return false;
39
- if (/\bgit\s+checkout\b/.test(command)) return true;
114
+ const stripSurrounding = (segment: string): string => segment.replace(/^[\s'"(){}!]+/, "").replace(/[\s'"()!}]+$/, "");
115
+ const stripQuotes = (value: string): string => value.trim().replace(/^['"]/, "").replace(/['"]$/, "");
40
116
 
41
- if (/\bgit\s+submodule\s+(status|init|summary)\b/.test(command)) return false;
42
- if (/\bgit\s+submodule\b/.test(command)) return true;
117
+ const stripEnvAssignments = (segment: string): string => {
118
+ let rest = segment;
119
+ for (;;) {
120
+ const match = rest.match(/^[A-Za-z_][A-Za-z0-9_]*=(?:(?:[^'"\s])|(?:'[^']*')|(?:"[^"]*"))*(?:\s|$)/);
121
+ if (!match) break;
122
+ rest = rest.slice(match[0].length).trimStart();
123
+ if (!rest) break;
124
+ }
125
+ return rest;
126
+ };
43
127
 
44
- if (/\bgit\s+worktree\s+list\b/.test(command)) return false;
45
- if (/\bgit\s+worktree\b/.test(command)) return true;
128
+ const stripPrefixes = (segment: string): string => {
129
+ let rest = segment;
130
+ for (;;) {
131
+ rest = stripEnvAssignments(rest);
132
+ if (!rest) break;
133
+ const match = rest.match(/^([A-Za-z_][A-Za-z0-9_]*)\b(?:\s|$)/);
134
+ if (!match) break;
135
+ const word = match[1].toLowerCase();
136
+ if (!PREFIXES.has(word) && !CONTROL_KEYWORDS.has(word)) break;
137
+ rest = rest.slice(match[0].length).trimStart();
138
+ if (!rest) break;
139
+ rest = rest.replace(/^\d+(?:\.\d+)?[a-z]*\s+/, "");
140
+ while (rest.startsWith("-")) {
141
+ const flag = rest.match(/^(\S+)(?:\s|$)/);
142
+ if (!flag) break;
143
+ rest = rest.slice(flag[0].length).trimStart();
144
+ if (!rest) break;
145
+ const next = rest.match(/^([^\s-][^\s]*)(?:\s|$)/);
146
+ if (!next) break;
147
+ const nextWord = next[1].toLowerCase();
148
+ if (nextWord === "git" || nextWord === "git.exe" || PREFIXES.has(nextWord) || CONTROL_KEYWORDS.has(nextWord)) break;
149
+ rest = rest.slice(next[0].length).trimStart();
150
+ }
151
+ }
152
+ return rest;
153
+ };
46
154
 
47
- if (/\bgit\s+stash\b/.test(command)) return true;
155
+ const classifyGitCommand = (rest: string): boolean => {
156
+ const tokens = rest.toLowerCase().split(/\s+/).filter(Boolean);
157
+ let subcommand: string | undefined;
158
+ let subcommandIndex = -1;
159
+ for (let i = 0; i < tokens.length; i++) {
160
+ const token = tokens[i];
161
+ if (GIT_META_OPTS.has(token)) return false;
162
+ if (GIT_OPTS_BARE.has(token)) continue;
163
+ if (GIT_OPTS_WITH_ARG.has(token)) {
164
+ i++;
165
+ continue;
166
+ }
167
+ if (token.startsWith("-")) continue;
168
+ subcommand = token;
169
+ subcommandIndex = i;
170
+ break;
171
+ }
172
+ if (subcommand === undefined) return false;
173
+ const rule = GIT_RULES[subcommand];
174
+ if (!rule) return false;
175
+ return rule(tokens.slice(subcommandIndex + 1));
176
+ };
48
177
 
49
- return false;
178
+ const containsBlockedGitCommand = (command: string, depth = 0): boolean => {
179
+ if (depth > 4) return true;
180
+ const masked = maskHeredocBodies(command);
181
+ return masked.split(/\n|;|\|\||&&|\||&|`|\$\(|<\(|>\(/).some((segment) => {
182
+ let rest = stripSurrounding(segment.trim());
183
+ if (!rest) return false;
184
+ rest = stripPrefixes(rest);
185
+ rest = stripSurrounding(rest);
186
+ if (!rest) return false;
187
+ const shell = rest.match(/^(sh|bash|zsh|dash|ksh|ash|fish)\s+-[a-zA-Z]*c[a-zA-Z]*\s+(.+)$/i);
188
+ if (shell) return containsBlockedGitCommand(stripQuotes(shell[2]), depth + 1);
189
+ const su = rest.match(/^su\b(.*?)\s+-c\s+(.+)$/i);
190
+ if (su) return containsBlockedGitCommand(stripQuotes(su[2]), depth + 1);
191
+ const pwsh = rest.match(/^(pwsh|powershell)\s+(-Command|-c)\s+(.+)$/i);
192
+ if (pwsh) return containsBlockedGitCommand(stripQuotes(pwsh[3]), depth + 1);
193
+ const git = rest.match(/^(?:.*\/)?git(\.exe)?\b(.*)$/i);
194
+ if (!git) return false;
195
+ return classifyGitCommand(git[2]);
196
+ });
50
197
  };
51
198
 
52
199
  pi.on("tool_call", async (event) => {
53
200
  if (event.toolName !== "bash") return undefined;
54
- const command = (event.input.command as string).trim();
55
- if (gitBlocked && containsBlockedGitCommand(command)) {
201
+ const command = event.input.command;
202
+ if (typeof command !== "string") return undefined;
203
+ const trimmed = command.trim();
204
+ if (gitBlocked && containsBlockedGitCommand(trimmed)) {
56
205
  return { block: true, reason: "Mutative git commands are blocked. Use /toggle-allow-git to allow for this session." };
57
206
  }
58
207
  return undefined;
59
208
  });
60
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
+
61
224
  pi.registerTool({
62
225
  name: "git_commit",
63
226
  label: "Git Commit",
@@ -70,33 +233,38 @@ export default function (pi: ExtensionAPI) {
70
233
  parameters: Type.Object({
71
234
  type: Type.Union(COMMIT_TYPES.map((t) => Type.Literal(t))),
72
235
  message: Type.String({
236
+ minLength: 1,
73
237
  description: "Commit message (imperative mood). Multi-line allowed for detailed changes.",
74
238
  }),
75
239
  }),
76
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
+ }
77
252
 
78
- const { type, message } = params;
79
- const fullMessage = `${type}: ${message}`;
80
- const addResult = await pi.exec("git", ["add", "."], { signal });
81
- if (addResult.code !== 0) {
82
- return { content: [{ type: "text", text: `Staging failed: ${addResult.stderr}` }], details: {}, isError: true };
83
- }
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
+ }
84
257
 
85
- const result = await pi.exec("git", ["commit", "-m", fullMessage], { signal });
86
- if (result.code !== 0) {
87
- return { content: [{ type: "text", text: `Commit failed: ${result.stderr}` }], details: {}, isError: true };
258
+ return { content: [{ type: "text", text: `✓ Committed: ${fullMessage}` }], details: {} };
259
+ } finally {
260
+ deactivateGitCommit();
88
261
  }
89
-
90
- return { content: [{ type: "text", text: `✓ Committed: ${fullMessage}` }], details: {} };
91
262
  },
92
263
  });
93
264
 
94
265
  pi.on("session_start", () => {
95
266
  gitBlocked = true;
96
- const activeTools = pi.getActiveTools();
97
- if (!activeTools.includes("git_commit")) {
98
- pi.setActiveTools([...activeTools, "git_commit"]);
99
- }
267
+ deactivateGitCommit();
100
268
  });
101
269
 
102
270
  pi.registerCommand("commit", {
@@ -133,6 +301,7 @@ export default function (pi: ExtensionAPI) {
133
301
  const diff = diffResult.stdout || "(no changes staged)";
134
302
 
135
303
  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.`;
304
+ activateGitCommit();
136
305
  pi.sendUserMessage(prompt, { deliverAs: "followUp" });
137
306
  } finally {
138
307
  ctx.ui.setWorkingMessage();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-git-commit",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "type": "module",
5
5
  "description": "Pi extension: block mutative git commands in bash and provide a git_commit tool plus /commit and /toggle-allow-git commands",
6
6
  "main": "index.ts",
@@ -21,7 +21,6 @@
21
21
  "license": "MIT",
22
22
  "files": [
23
23
  "index.ts",
24
- "src",
25
24
  "README.md",
26
25
  "LICENSE"
27
26
  ],