pi-git-commit 1.0.0 → 1.0.2

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 +78 -8
  2. package/index.ts +175 -35
  3. package/package.json +1 -2
package/README.md CHANGED
@@ -1,13 +1,38 @@
1
1
  # pi-git-commit
2
2
 
3
- A [pi-coding-agent](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent) extension that keeps mutative git operations out of the agent's bash and provides a safe commit flow.
3
+ Keeps mutative git operations out of the agent's bash and provides a safe, reviewable commit flow in [pi-coding-agent](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent): a bash guard, a `git_commit` tool, and `/commit` + `/toggle-allow-git` commands.
4
4
 
5
- ## Features
5
+ ## What you get
6
6
 
7
- - **Bash git guard.** Blocks `git add`, `commit`, `push`, `pull`, `merge`, `rebase`, `reset`, `clean`, `rm`, `restore`, `switch`, `cherry-pick`, `revert`, `mv`, `init`, `clone` and other mutative forms in the agent's bash tool. Read-only commands (`status`, `diff`, `log`, `fetch`, `branch`, `tag`, `stash list`, ...) stay allowed.
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
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.** Stages all changes, shows the staged diff, and asks the agent to review it and commit via `git_commit` (never via bash).
10
- - **`/toggle-allow-git` command.** Temporarily allows mutative git commands in bash for the current session.
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.
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
+
12
+ ## Quick start
13
+
14
+ 1. Make your changes, then run:
15
+
16
+ ```text
17
+ /commit
18
+ ```
19
+
20
+ 2. The extension stages the working tree and hands the staged diff to the agent with instructions to review it.
21
+
22
+ 3. The agent commits using the `git_commit` tool:
23
+
24
+ ```json
25
+ {
26
+ "type": "FIX",
27
+ "message": "Fix off-by-one in the retry loop"
28
+ }
29
+ ```
30
+
31
+ 4. If you need to run mutative git yourself, allow it for the session:
32
+
33
+ ```text
34
+ /toggle-allow-git
35
+ ```
11
36
 
12
37
  ## Installation
13
38
 
@@ -15,8 +40,53 @@ A [pi-coding-agent](https://github.com/badlogic/pi-mono/tree/main/packages/codin
15
40
  pi install npm:pi-git-commit
16
41
  ```
17
42
 
18
- ## Usage
43
+ From a local checkout:
44
+
45
+ ```bash
46
+ pi install /path/to/pi-git-commit
47
+ ```
48
+
49
+ ## The git_commit tool
50
+
51
+ | Field | Description |
52
+ | --- | --- |
53
+ | `type` | `FIX` (bug fix), `IMPROVE` (improvement), or `NEW` (new feature). |
54
+ | `message` | Commit message in imperative mood. Multi-line allowed for detailed changes. |
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`.
57
+
58
+ ## The bash guard
59
+
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
+
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.
63
+
64
+ A blocked command returns:
65
+
66
+ ```text
67
+ Mutative git commands are blocked. Use /toggle-allow-git to allow for this session.
68
+ ```
69
+
70
+ ## Troubleshooting
71
+
72
+ - **The agent refuses to commit.** The guard blocks `git commit` in bash by design. Run `/commit` and let the agent use the `git_commit` tool.
73
+ - **"Nothing to commit (empty diff)."** There are no staged changes — make edits first, then run `/commit` again.
74
+ - **I need git in bash right now.** Run `/toggle-allow-git`; the guard re-arms automatically on the next session start.
75
+
76
+ ## Development
77
+
78
+ Requires [Node.js](https://nodejs.org) ≥ 22.19 and npm.
79
+
80
+ ```bash
81
+ npm install
82
+ npm test
83
+ npm run typecheck
84
+ ```
85
+
86
+ ## Credits
87
+
88
+ - [badlogic](https://github.com/badlogic), pi-coding-agent and the tool/command APIs
19
89
 
20
- Run `/commit` after making changes. The extension stages the working tree, presents the diff to the agent, and the agent commits using the `git_commit` tool.
90
+ ## License
21
91
 
22
- Use `/toggle-allow-git` if you need to run mutative git commands in bash yourself for the current session (the guard re-arms on the next session).
92
+ [MIT](LICENSE)
package/index.ts CHANGED
@@ -6,47 +6,182 @@ 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);
32
19
 
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;
20
+ const GIT_RULES: Record<string, (args: string[]) => boolean> = {
21
+ add: blockAll,
22
+ commit: blockAll,
23
+ push: blockAll,
24
+ pull: blockAll,
25
+ merge: blockAll,
26
+ rebase: blockAll,
27
+ reset: blockAll,
28
+ clean: blockAll,
29
+ rm: blockAll,
30
+ restore: blockAll,
31
+ switch: blockAll,
32
+ "cherry-pick": blockAll,
33
+ revert: blockAll,
34
+ mv: blockAll,
35
+ init: blockAll,
36
+ clone: blockAll,
37
+ am: blockAll,
38
+ replace: blockAll,
39
+ "update-ref": blockAll,
40
+ "symbolic-ref": blockAll,
41
+ "update-index": blockAll,
42
+ gc: blockAll,
43
+ maintenance: blockAll,
44
+ "filter-branch": blockAll,
45
+ "filter-repo": blockAll,
46
+ "fast-import": blockAll,
47
+ prune: blockAll,
48
+ repack: blockAll,
49
+ "pack-refs": blockAll,
50
+ mergetool: blockAll,
51
+ bisect: blockAll,
52
+ subtree: blockAll,
53
+ fetch: (args) => hasAny(args, ["--prune", "-p", "-P", "--prune-tags"]),
54
+ config: (args) => {
55
+ if (hasAny(args, ["--add", "--unset", "--unset-all", "--replace-all", "--remove-section", "--rename-section", "--edit", "-e"])) return true;
56
+ return !hasAny(args, ["--list", "-l", "--get", "--get-all", "--get-regexp", "--show-origin", "--show-scope"]);
57
+ },
58
+ remote: (args) => !(args.length === 0 || args.includes("-v") || hasAny(args, ["show", "get-url"])),
59
+ apply: allowOnly(["--check", "--stat"]),
60
+ notes: allowOnly(["list", "show"]),
61
+ lfs: allowOnly(["ls-files", "status"]),
62
+ "sparse-checkout": allowOnly(["list"]),
63
+ stash: allowOnly(["list", "show"]),
64
+ submodule: allowOnly(["status", "init", "summary"]),
65
+ worktree: allowOnly(["list"]),
66
+ reflog: (args) => !(args.length === 0 || hasAny(args, ["show"])),
67
+ branch: (args) => args.some((arg) => ["-d", "-m", "--delete", "--move", "--prune", "--unset-upstream", "--edit-description"].includes(arg) || arg.startsWith("--set-upstream-to")),
68
+ tag: (args) => {
69
+ if (args.length === 0) return false;
70
+ const first = args[0];
71
+ if (first === "-l" || first === "--list" || first.startsWith("-n")) return false;
72
+ return !["--contains", "--merged", "--no-merged", "--points-at", "--sort", "--format", "--column", "--no-column", "--color", "--ignore-case", "--verbose", "-v"].some((flag) => first.startsWith(flag));
73
+ },
74
+ checkout: (args) => {
75
+ if (args[0] !== "--") return true;
76
+ const path = args[1];
77
+ if (path === undefined) return true;
78
+ if (args.length > 2) return true;
79
+ return path === "." || path === ".." || path.endsWith("/");
80
+ },
81
+ };
82
+
83
+ const maskHeredocBodies = (command: string): string => {
84
+ let masked = "";
85
+ let cursor = 0;
86
+ const heredocRe = /<<-?\s*['"]?([A-Za-z_][A-Za-z0-9_]*)['"]?/g;
87
+ let match: RegExpExecArray | null;
88
+ while ((match = heredocRe.exec(command)) !== null) {
89
+ const current = match;
90
+ if (current.index < cursor) continue;
91
+ const tail = command.slice(current.index + current[0].length);
92
+ const lines = tail.split("\n");
93
+ const end = lines.findIndex((line) => line.trim() === current[1]);
94
+ if (end === -1) continue;
95
+ const body = lines.slice(0, end + 1);
96
+ masked += command.slice(cursor, current.index) + current[0] + body.map((line) => " ".repeat(line.length)).join("\n");
97
+ cursor = current.index + current[0].length + body.join("\n").length;
36
98
  }
99
+ return masked + command.slice(cursor);
100
+ };
37
101
 
38
- if (/\bgit\s+checkout\s+--\s/.test(command)) return false;
39
- if (/\bgit\s+checkout\b/.test(command)) return true;
102
+ const stripSurrounding = (segment: string): string => segment.replace(/^[\s'"(){}!]+/, "").replace(/[\s'"()!}]+$/, "");
103
+ const stripQuotes = (value: string): string => value.trim().replace(/^['"]/, "").replace(/['"]$/, "");
40
104
 
41
- if (/\bgit\s+submodule\s+(status|init|summary)\b/.test(command)) return false;
42
- if (/\bgit\s+submodule\b/.test(command)) return true;
105
+ const stripEnvAssignments = (segment: string): string => {
106
+ let rest = segment;
107
+ for (;;) {
108
+ const match = rest.match(/^[A-Za-z_][A-Za-z0-9_]*=(?:(?:[^'"\s])|(?:'[^']*')|(?:"[^"]*"))*(?:\s|$)/);
109
+ if (!match) break;
110
+ rest = rest.slice(match[0].length).trimStart();
111
+ if (!rest) break;
112
+ }
113
+ return rest;
114
+ };
43
115
 
44
- if (/\bgit\s+worktree\s+list\b/.test(command)) return false;
45
- if (/\bgit\s+worktree\b/.test(command)) return true;
116
+ const stripPrefixes = (segment: string): string => {
117
+ let rest = segment;
118
+ for (let i = 0; i < 5; i++) {
119
+ rest = stripEnvAssignments(rest);
120
+ if (!rest) break;
121
+ const match = rest.match(/^([A-Za-z_][A-Za-z0-9_]*)\b(?:\s|$)/);
122
+ if (!match) break;
123
+ const word = match[1].toLowerCase();
124
+ if (!PREFIXES.has(word) && !CONTROL_KEYWORDS.has(word)) break;
125
+ rest = rest.slice(match[0].length).trimStart();
126
+ if (!rest) break;
127
+ rest = rest.replace(/^\d+(?:\.\d+)?[a-z]*\s+/, "");
128
+ while (rest.startsWith("-")) {
129
+ const flag = rest.match(/^(\S+)(?:\s|$)/);
130
+ if (!flag) break;
131
+ rest = rest.slice(flag[0].length).trimStart();
132
+ if (!rest) break;
133
+ const next = rest.match(/^([^\s-][^\s]*)(?:\s|$)/);
134
+ if (!next) break;
135
+ const nextWord = next[1].toLowerCase();
136
+ if (nextWord === "git" || nextWord === "git.exe" || PREFIXES.has(nextWord) || CONTROL_KEYWORDS.has(nextWord)) break;
137
+ rest = rest.slice(next[0].length).trimStart();
138
+ }
139
+ }
140
+ return rest;
141
+ };
46
142
 
47
- if (/\bgit\s+stash\b/.test(command)) return true;
143
+ const classifyGitCommand = (rest: string): boolean => {
144
+ const tokens = rest.toLowerCase().split(/\s+/).filter(Boolean);
145
+ let subcommand: string | undefined;
146
+ let subcommandIndex = -1;
147
+ for (let i = 0; i < tokens.length; i++) {
148
+ const token = tokens[i];
149
+ if (GIT_META_OPTS.has(token)) return false;
150
+ if (GIT_OPTS_BARE.has(token)) continue;
151
+ if (GIT_OPTS_WITH_ARG.has(token)) {
152
+ i++;
153
+ continue;
154
+ }
155
+ if (token.startsWith("-")) continue;
156
+ subcommand = token;
157
+ subcommandIndex = i;
158
+ break;
159
+ }
160
+ if (subcommand === undefined) return false;
161
+ const rule = GIT_RULES[subcommand];
162
+ if (!rule) return false;
163
+ return rule(tokens.slice(subcommandIndex + 1));
164
+ };
48
165
 
49
- return false;
166
+ const containsBlockedGitCommand = (command: string, depth = 0): boolean => {
167
+ if (depth > 4) return false;
168
+ const masked = maskHeredocBodies(command);
169
+ return masked.split(/\n|;|\|\||&&|\||&|`|\$\(|<\(|>\(/).some((segment) => {
170
+ let rest = stripSurrounding(segment.trim());
171
+ if (!rest) return false;
172
+ rest = stripPrefixes(rest);
173
+ rest = stripSurrounding(rest);
174
+ if (!rest) return false;
175
+ const shell = rest.match(/^(sh|bash|zsh|dash|ksh|ash|fish)\s+-[a-zA-Z]*c[a-zA-Z]*\s+(.+)$/i);
176
+ if (shell) return containsBlockedGitCommand(stripQuotes(shell[2]), depth + 1);
177
+ const su = rest.match(/^su\b(.*?)\s+-c\s+(.+)$/i);
178
+ if (su) return containsBlockedGitCommand(stripQuotes(su[2]), depth + 1);
179
+ const pwsh = rest.match(/^(pwsh|powershell)\s+(-Command|-c)\s+(.+)$/i);
180
+ if (pwsh) return containsBlockedGitCommand(stripQuotes(pwsh[3]), depth + 1);
181
+ const git = rest.match(/^(?:.*\/)?git(\.exe)?\b(.*)$/i);
182
+ if (!git) return false;
183
+ return classifyGitCommand(git[2]);
184
+ });
50
185
  };
51
186
 
52
187
  pi.on("tool_call", async (event) => {
@@ -70,13 +205,18 @@ export default function (pi: ExtensionAPI) {
70
205
  parameters: Type.Object({
71
206
  type: Type.Union(COMMIT_TYPES.map((t) => Type.Literal(t))),
72
207
  message: Type.String({
208
+ minLength: 1,
73
209
  description: "Commit message (imperative mood). Multi-line allowed for detailed changes.",
74
210
  }),
75
211
  }),
76
212
  async execute(_toolCallId, params, signal, _onUpdate, _ctx) {
77
213
 
78
214
  const { type, message } = params;
79
- const fullMessage = `${type}: ${message}`;
215
+ const trimmedMessage = message.trim();
216
+ if (!trimmedMessage) {
217
+ return { content: [{ type: "text", text: "Commit message must not be empty." }], details: {}, isError: true };
218
+ }
219
+ const fullMessage = `${type}: ${trimmedMessage}`;
80
220
  const addResult = await pi.exec("git", ["add", "."], { signal });
81
221
  if (addResult.code !== 0) {
82
222
  return { content: [{ type: "text", text: `Staging failed: ${addResult.stderr}` }], details: {}, isError: true };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-git-commit",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
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
  ],