@jdiamond/pi-git 0.1.0
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 +32 -0
- package/package.json +54 -0
- package/src/index.ts +170 -0
package/README.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# pi-git
|
|
2
|
+
|
|
3
|
+
**Review-gated git/GitHub tools for [pi](https://github.com/mariozechner/pi-coding-agent)**
|
|
4
|
+
|
|
5
|
+
Every action that publishes content shows a review overlay before executing: approve, edit, or cancel. No accidental commits or PRs.
|
|
6
|
+
|
|
7
|
+
## Tools
|
|
8
|
+
|
|
9
|
+
| Tool | Description |
|
|
10
|
+
|------|-------------|
|
|
11
|
+
| `git_commit` | Stage files and commit with a review step |
|
|
12
|
+
| (more coming) | |
|
|
13
|
+
|
|
14
|
+
All tools use a `git_` prefix to avoid conflicts.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```json
|
|
19
|
+
{
|
|
20
|
+
"packages": ["npm:@jdiamond/pi-git"]
|
|
21
|
+
}
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Usage
|
|
25
|
+
|
|
26
|
+
The agent stages files and commits in one call:
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
git_commit(message: "Fix: resolve race condition in pool shutdown", files: ["src/pool.ts"])
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
A review overlay shows the commit message and lets you **[a]**pprove, **[e]**dit, or **[c]**ancel before the commit executes.
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@jdiamond/pi-git",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Review-gated git/GitHub tools for pi. Every action that creates or publishes content shows a review UI before executing.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"git",
|
|
8
|
+
"github",
|
|
9
|
+
"review",
|
|
10
|
+
"commit"
|
|
11
|
+
],
|
|
12
|
+
"author": "Jason Diamond <jason@diamond.name>",
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/jdiamond/pi-git.git"
|
|
17
|
+
},
|
|
18
|
+
"homepage": "https://github.com/jdiamond/pi-git",
|
|
19
|
+
"bugs": {
|
|
20
|
+
"url": "https://github.com/jdiamond/pi-git/issues"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"src",
|
|
24
|
+
"README.md"
|
|
25
|
+
],
|
|
26
|
+
"type": "module",
|
|
27
|
+
"scripts": {
|
|
28
|
+
"test": "node --test test/**/*.ts",
|
|
29
|
+
"typecheck": "tsc --noEmit",
|
|
30
|
+
"lint": "biome check",
|
|
31
|
+
"lint:fix": "biome check --write",
|
|
32
|
+
"format": "biome check --write",
|
|
33
|
+
"format:check": "biome check",
|
|
34
|
+
"check": "npm run typecheck && npm run lint && npm run format:check",
|
|
35
|
+
"verify": "npm run check && npm test"
|
|
36
|
+
},
|
|
37
|
+
"pi": {
|
|
38
|
+
"extensions": [
|
|
39
|
+
"./src/index.ts"
|
|
40
|
+
]
|
|
41
|
+
},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
44
|
+
"typebox": "*"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@biomejs/biome": "2.4.16",
|
|
48
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
49
|
+
"@earendil-works/pi-tui": "*",
|
|
50
|
+
"@types/node": "^25",
|
|
51
|
+
"typebox": "1.1.39",
|
|
52
|
+
"typescript": "6.0.3"
|
|
53
|
+
}
|
|
54
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import type {
|
|
4
|
+
ExtensionAPI,
|
|
5
|
+
ExtensionContext,
|
|
6
|
+
} from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { Type } from "typebox";
|
|
8
|
+
|
|
9
|
+
function isGitRepo(cwd: string): boolean {
|
|
10
|
+
const result = spawnSync("git", ["rev-parse", "--git-dir"], {
|
|
11
|
+
cwd,
|
|
12
|
+
stdio: "ignore",
|
|
13
|
+
});
|
|
14
|
+
return result.status === 0;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function hasStagedChanges(cwd: string): boolean {
|
|
18
|
+
const result = spawnSync("git", ["diff", "--cached", "--quiet"], {
|
|
19
|
+
cwd,
|
|
20
|
+
stdio: "ignore",
|
|
21
|
+
});
|
|
22
|
+
return result.status !== 0;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function getStagedFiles(cwd: string): string[] {
|
|
26
|
+
const result = spawnSync("git", ["diff", "--cached", "--name-only"], {
|
|
27
|
+
cwd,
|
|
28
|
+
encoding: "utf-8",
|
|
29
|
+
stdio: "pipe",
|
|
30
|
+
});
|
|
31
|
+
if (result.error) throw result.error;
|
|
32
|
+
if (result.status !== 0) {
|
|
33
|
+
throw new Error(result.stderr.trim() || "git diff --cached failed");
|
|
34
|
+
}
|
|
35
|
+
return result.stdout.trim().split("\n").filter(Boolean);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function stageFiles(cwd: string, files: string[]): void {
|
|
39
|
+
const result = spawnSync("git", ["add", "--", ...files], {
|
|
40
|
+
cwd,
|
|
41
|
+
stdio: "pipe",
|
|
42
|
+
encoding: "utf-8",
|
|
43
|
+
});
|
|
44
|
+
if (result.error) throw result.error;
|
|
45
|
+
if (result.status !== 0) {
|
|
46
|
+
throw new Error(result.stderr.trim() || "git add failed");
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function runCommit(cwd: string, message: string): string {
|
|
51
|
+
const result = spawnSync("git", ["commit", "-m", message], {
|
|
52
|
+
cwd,
|
|
53
|
+
encoding: "utf-8",
|
|
54
|
+
stdio: "pipe",
|
|
55
|
+
});
|
|
56
|
+
if (result.error) throw result.error;
|
|
57
|
+
if (result.status !== 0) {
|
|
58
|
+
throw new Error(result.stderr.trim() || "git commit failed");
|
|
59
|
+
}
|
|
60
|
+
return result.stdout.trim();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function formatFilesSection(
|
|
64
|
+
files: string[],
|
|
65
|
+
label: string,
|
|
66
|
+
): string | undefined {
|
|
67
|
+
if (!files.length) return undefined;
|
|
68
|
+
return `${label}:\n ${files.join("\n ")}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function buildCommitSections(
|
|
72
|
+
files: string[] | undefined,
|
|
73
|
+
cwd: string,
|
|
74
|
+
): string[] {
|
|
75
|
+
const sections: string[] = [];
|
|
76
|
+
|
|
77
|
+
if (files?.length) {
|
|
78
|
+
const existingFiles = getStagedFiles(cwd).filter((f) => !files.includes(f));
|
|
79
|
+
const es = formatFilesSection(existingFiles, "Already staged");
|
|
80
|
+
if (es) sections.push(es);
|
|
81
|
+
|
|
82
|
+
const s = formatFilesSection(files, "Files to stage");
|
|
83
|
+
if (s) sections.push(s);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return sections;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function reviewCommit(
|
|
90
|
+
ctx: ExtensionContext,
|
|
91
|
+
cwd: string,
|
|
92
|
+
initialMessage: string,
|
|
93
|
+
files?: string[],
|
|
94
|
+
): Promise<{ message: string; approved: boolean }> {
|
|
95
|
+
let message = initialMessage;
|
|
96
|
+
const sections = buildCommitSections(files, cwd);
|
|
97
|
+
const header = sections.length
|
|
98
|
+
? `📝 Git Commit:\n\n${sections.join("\n\n")}\n\n`
|
|
99
|
+
: `📝 Git Commit:\n\n`;
|
|
100
|
+
|
|
101
|
+
for (;;) {
|
|
102
|
+
const choice = await ctx.ui.select(`${header}${message}`, [
|
|
103
|
+
"Approve",
|
|
104
|
+
"Edit",
|
|
105
|
+
"Cancel",
|
|
106
|
+
]);
|
|
107
|
+
|
|
108
|
+
if (choice === "Approve") return { message, approved: true };
|
|
109
|
+
if (choice === "Cancel" || choice === undefined) {
|
|
110
|
+
return { message, approved: false };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// edit
|
|
114
|
+
const edited = await ctx.ui.editor("Edit commit message:", message);
|
|
115
|
+
if (edited === undefined || edited.trim() === "") {
|
|
116
|
+
return { message, approved: false };
|
|
117
|
+
}
|
|
118
|
+
message = edited;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export default function (pi: ExtensionAPI) {
|
|
123
|
+
pi.registerTool({
|
|
124
|
+
name: "git_commit",
|
|
125
|
+
label: "Git Commit",
|
|
126
|
+
description:
|
|
127
|
+
"Create a git commit. Optionally takes a list of files to stage; otherwise commits already-staged changes. Shows a review overlay where the user can approve, edit, or cancel before the commit executes.",
|
|
128
|
+
parameters: Type.Object({
|
|
129
|
+
message: Type.String({ description: "The commit message" }),
|
|
130
|
+
files: Type.Optional(
|
|
131
|
+
Type.Array(Type.String(), {
|
|
132
|
+
description:
|
|
133
|
+
"Files to stage and commit (if omitted, commits already-staged changes)",
|
|
134
|
+
}),
|
|
135
|
+
),
|
|
136
|
+
}),
|
|
137
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
138
|
+
const cwd = resolve(ctx.cwd);
|
|
139
|
+
|
|
140
|
+
if (!isGitRepo(cwd)) {
|
|
141
|
+
throw new Error("Not inside a git repository.");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const files = params.files?.length ? params.files : undefined;
|
|
145
|
+
|
|
146
|
+
if (!files && !hasStagedChanges(cwd)) {
|
|
147
|
+
throw new Error(
|
|
148
|
+
"Nothing staged for commit. Pass `files` to stage specific files, or stage changes first.",
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const result = await reviewCommit(ctx, cwd, params.message, files);
|
|
153
|
+
|
|
154
|
+
if (!result.approved) {
|
|
155
|
+
throw new Error("Commit cancelled by user.");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (files) {
|
|
159
|
+
stageFiles(cwd, files);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const output = runCommit(cwd, result.message);
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
content: [{ type: "text" as const, text: output || "Commit created." }],
|
|
166
|
+
details: { message: result.message, output, files },
|
|
167
|
+
};
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
}
|