pi-git-status-line 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/LICENSE +21 -0
- package/README.md +96 -0
- package/extensions/git-status-line.ts +229 -0
- package/package.json +45 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tony David Sprotte
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# pi-git-status-line
|
|
2
|
+
|
|
3
|
+
A standalone [Pi](https://github.com/badlogic/pi-mono) package that extends Pi's status line below the input with git information for the current working tree:
|
|
4
|
+
|
|
5
|
+
- current branch name
|
|
6
|
+
- amount of uncommitted files
|
|
7
|
+
- commits to be pushed (`↑`)
|
|
8
|
+
- commits to be pulled (`↓`)
|
|
9
|
+
|
|
10
|
+
It keeps Pi's existing footer/status line and adds a git summary via `ctx.ui.setStatus(...)`.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
### From npm
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pi install npm:pi-git-status-line
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
### From git
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pi install git:github.com/qualiti/pi-git-status-line
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
### From a local checkout
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pi install /absolute/path/to/pi-git-status-line
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Usage
|
|
33
|
+
|
|
34
|
+
After installation, reload Pi or start a new session.
|
|
35
|
+
|
|
36
|
+
When you are inside a git repository, the footer will show a summary like:
|
|
37
|
+
|
|
38
|
+
```text
|
|
39
|
+
main · 2 uncommitted · ↑1 ↓0
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
If the repo is clean:
|
|
43
|
+
|
|
44
|
+
```text
|
|
45
|
+
main · ✓ clean · ↑0 ↓0
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
If the branch has no upstream yet:
|
|
49
|
+
|
|
50
|
+
```text
|
|
51
|
+
feature/new-branch · 1 uncommitted · no upstream
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Notes
|
|
55
|
+
|
|
56
|
+
- works only when the current working directory is inside a git repository
|
|
57
|
+
- refreshes on session start, after turns, and after `bash`, `write`, or `edit` tool executions
|
|
58
|
+
- if Pi is not in a git repo, the extension hides its status text
|
|
59
|
+
|
|
60
|
+
## Requirements
|
|
61
|
+
|
|
62
|
+
- `git` must be available on `PATH`
|
|
63
|
+
|
|
64
|
+
## Package structure
|
|
65
|
+
|
|
66
|
+
This package follows Pi package guidelines:
|
|
67
|
+
|
|
68
|
+
- it declares a `pi` manifest in `package.json`
|
|
69
|
+
- it exposes the extension through `./extensions`
|
|
70
|
+
- it ships TypeScript directly so Pi can load it through jiti
|
|
71
|
+
|
|
72
|
+
## Local development
|
|
73
|
+
|
|
74
|
+
Run the unit tests:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
npm test
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Run a local package install into Pi:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
pi install /absolute/path/to/pi-git-status-line
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Or load the extension file directly for quick testing:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
pi -e /absolute/path/to/pi-git-status-line/extensions/git-status-line.ts
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Files
|
|
93
|
+
|
|
94
|
+
- `package.json` — Pi package manifest for npm and git installs
|
|
95
|
+
- `extensions/git-status-line.ts` — the extension entry point
|
|
96
|
+
- `tests/git-status-line.test.ts` — helper tests
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { access } from "node:fs/promises";
|
|
2
|
+
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
3
|
+
|
|
4
|
+
type ExecResult = {
|
|
5
|
+
stdout: string;
|
|
6
|
+
stderr: string;
|
|
7
|
+
code: number;
|
|
8
|
+
killed?: boolean;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
type RepoState = {
|
|
12
|
+
branch: string;
|
|
13
|
+
upstream?: string;
|
|
14
|
+
changedFileCount: number;
|
|
15
|
+
aheadCount: number;
|
|
16
|
+
behindCount: number;
|
|
17
|
+
conflictCount: number;
|
|
18
|
+
inProgress: string[];
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const STATUS_KEY = "git-status-line";
|
|
22
|
+
const REFRESH_TOOL_NAMES = new Set(["bash", "write", "edit"]);
|
|
23
|
+
|
|
24
|
+
export default function gitStatusLineExtension(pi: ExtensionAPI) {
|
|
25
|
+
const refresh = async (ctx: ExtensionContext) => {
|
|
26
|
+
await updateStatusLine(pi, ctx);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
30
|
+
await refresh(ctx);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
pi.on("turn_end", async (_event, ctx) => {
|
|
34
|
+
await refresh(ctx);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
pi.on("tool_execution_end", async (event, ctx) => {
|
|
38
|
+
if (REFRESH_TOOL_NAMES.has(event.toolName)) {
|
|
39
|
+
await refresh(ctx);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
pi.on("session_shutdown", async (_event, ctx) => {
|
|
44
|
+
if (ctx.hasUI) ctx.ui.setStatus(STATUS_KEY, "");
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function updateStatusLine(pi: ExtensionAPI, ctx: ExtensionContext) {
|
|
49
|
+
if (!ctx.hasUI) return;
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
const repo = await inspectRepo(pi, ctx.cwd);
|
|
53
|
+
ctx.ui.setStatus(STATUS_KEY, formatGitStatusLine(repo, ctx.ui.theme));
|
|
54
|
+
} catch {
|
|
55
|
+
ctx.ui.setStatus(STATUS_KEY, "");
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function inspectRepo(pi: ExtensionAPI, cwd: string): Promise<RepoState> {
|
|
60
|
+
const inside = await run(pi, cwd, "git", ["rev-parse", "--is-inside-work-tree"]);
|
|
61
|
+
if (inside.code !== 0 || inside.stdout.trim() !== "true") {
|
|
62
|
+
throw new Error("Not inside a git repository.");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const rootResult = await run(pi, cwd, "git", ["rev-parse", "--show-toplevel"]);
|
|
66
|
+
if (rootResult.code !== 0) {
|
|
67
|
+
throw new Error(cleanErrorText(rootResult) || "Could not determine the repository root.");
|
|
68
|
+
}
|
|
69
|
+
const root = rootResult.stdout.trim();
|
|
70
|
+
|
|
71
|
+
const branchResult = await run(pi, root, "git", ["branch", "--show-current"]);
|
|
72
|
+
if (branchResult.code !== 0) {
|
|
73
|
+
throw new Error(cleanErrorText(branchResult) || "Could not determine the current branch.");
|
|
74
|
+
}
|
|
75
|
+
const branch = branchResult.stdout.trim();
|
|
76
|
+
if (!branch) {
|
|
77
|
+
throw new Error("Detached HEAD.");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const statusResult = await run(pi, root, "git", ["status", "--porcelain=v1", "--branch"]);
|
|
81
|
+
if (statusResult.code !== 0) {
|
|
82
|
+
throw new Error(cleanErrorText(statusResult) || "Could not inspect git status.");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const upstreamResult = await run(pi, root, "git", ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]);
|
|
86
|
+
const upstream = upstreamResult.code === 0 ? upstreamResult.stdout.trim() || undefined : undefined;
|
|
87
|
+
|
|
88
|
+
let aheadCount = 0;
|
|
89
|
+
let behindCount = 0;
|
|
90
|
+
if (upstream) {
|
|
91
|
+
const counts = await run(pi, root, "git", ["rev-list", "--left-right", "--count", `${upstream}...HEAD`]);
|
|
92
|
+
if (counts.code === 0) {
|
|
93
|
+
const [behindText, aheadText] = counts.stdout.trim().split(/\s+/);
|
|
94
|
+
behindCount = Number.parseInt(behindText ?? "0", 10) || 0;
|
|
95
|
+
aheadCount = Number.parseInt(aheadText ?? "0", 10) || 0;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const inProgress = await detectInProgressOperations(pi, root);
|
|
100
|
+
const parsedStatus = parseStatus(statusResult.stdout);
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
branch,
|
|
104
|
+
upstream,
|
|
105
|
+
changedFileCount: parsedStatus.changedFileCount,
|
|
106
|
+
aheadCount,
|
|
107
|
+
behindCount,
|
|
108
|
+
conflictCount: parsedStatus.conflictCount,
|
|
109
|
+
inProgress,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function parseStatus(statusText: string) {
|
|
114
|
+
let stagedCount = 0;
|
|
115
|
+
let unstagedCount = 0;
|
|
116
|
+
let untrackedCount = 0;
|
|
117
|
+
let conflictCount = 0;
|
|
118
|
+
let changedFileCount = 0;
|
|
119
|
+
|
|
120
|
+
for (const line of statusText.split(/\r?\n/)) {
|
|
121
|
+
if (!line || line.startsWith("## ") || line.startsWith("!! ")) continue;
|
|
122
|
+
changedFileCount += 1;
|
|
123
|
+
|
|
124
|
+
if (line.startsWith("?? ")) {
|
|
125
|
+
untrackedCount += 1;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const x = line[0] ?? " ";
|
|
130
|
+
const y = line[1] ?? " ";
|
|
131
|
+
if (isUnmergedStatus(x, y)) {
|
|
132
|
+
conflictCount += 1;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (x !== " ") stagedCount += 1;
|
|
137
|
+
if (y !== " ") unstagedCount += 1;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return { stagedCount, unstagedCount, untrackedCount, conflictCount, changedFileCount };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function isUnmergedStatus(x: string, y: string) {
|
|
144
|
+
const pair = `${x}${y}`;
|
|
145
|
+
return new Set(["DD", "AU", "UD", "UA", "DU", "AA", "UU"]).has(pair);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function detectInProgressOperations(pi: ExtensionAPI, cwd: string) {
|
|
149
|
+
const operations: string[] = [];
|
|
150
|
+
const candidates: Array<[string, string]> = [
|
|
151
|
+
["MERGE_HEAD", "merge"],
|
|
152
|
+
["CHERRY_PICK_HEAD", "cherry-pick"],
|
|
153
|
+
["REVERT_HEAD", "revert"],
|
|
154
|
+
["rebase-merge", "rebase"],
|
|
155
|
+
["rebase-apply", "rebase"],
|
|
156
|
+
];
|
|
157
|
+
|
|
158
|
+
for (const [gitPathName, label] of candidates) {
|
|
159
|
+
const gitPath = await run(pi, cwd, "git", ["rev-parse", "--git-path", gitPathName]);
|
|
160
|
+
if (gitPath.code !== 0) continue;
|
|
161
|
+
if (await pathExists(gitPath.stdout.trim())) {
|
|
162
|
+
if (!operations.includes(label)) operations.push(label);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return operations;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function formatGitStatusLine(repo: RepoState, theme: { fg(color: string, text: string): string }) {
|
|
170
|
+
const branch = theme.fg("accent", ` ${repo.branch}`);
|
|
171
|
+
|
|
172
|
+
let workingTree = repo.changedFileCount === 0
|
|
173
|
+
? theme.fg("success", "✓ clean")
|
|
174
|
+
: theme.fg(repo.conflictCount > 0 ? "error" : "warning", `${repo.changedFileCount} uncommitted`);
|
|
175
|
+
|
|
176
|
+
if (repo.inProgress.length > 0) {
|
|
177
|
+
workingTree += theme.fg("warning", ` (${repo.inProgress.join(", ")})`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const sync = repo.upstream
|
|
181
|
+
? formatAheadBehind(repo.aheadCount, repo.behindCount, theme)
|
|
182
|
+
: theme.fg("dim", "no upstream");
|
|
183
|
+
|
|
184
|
+
return [branch, workingTree, sync].join(theme.fg("dim", " · "));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function formatAheadBehind(aheadCount: number, behindCount: number, theme: { fg(color: string, text: string): string }) {
|
|
188
|
+
const ahead = aheadCount > 0 ? theme.fg("warning", `↑${aheadCount}`) : theme.fg("dim", "↑0");
|
|
189
|
+
const behind = behindCount > 0 ? theme.fg("warning", `↓${behindCount}`) : theme.fg("dim", "↓0");
|
|
190
|
+
return `${ahead} ${behind}`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function run(
|
|
194
|
+
pi: ExtensionAPI,
|
|
195
|
+
cwd: string,
|
|
196
|
+
command: string,
|
|
197
|
+
args: string[],
|
|
198
|
+
timeout = 10_000,
|
|
199
|
+
): Promise<ExecResult> {
|
|
200
|
+
const result = await pi.exec(command, args, { cwd, timeout });
|
|
201
|
+
return {
|
|
202
|
+
stdout: result.stdout,
|
|
203
|
+
stderr: result.stderr,
|
|
204
|
+
code: result.code,
|
|
205
|
+
killed: result.killed,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function pathExists(filePath: string) {
|
|
210
|
+
try {
|
|
211
|
+
await access(filePath);
|
|
212
|
+
return true;
|
|
213
|
+
} catch {
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function cleanErrorText(result: ExecResult) {
|
|
219
|
+
const text = [result.stderr, result.stdout].filter(Boolean).join("\n").trim();
|
|
220
|
+
return text || undefined;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export const __test__ = {
|
|
224
|
+
parseStatus,
|
|
225
|
+
isUnmergedStatus,
|
|
226
|
+
formatGitStatusLine,
|
|
227
|
+
formatAheadBehind,
|
|
228
|
+
cleanErrorText,
|
|
229
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-git-status-line",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Shareable Pi package that extends the status line with git branch, uncommitted files, and ahead/behind counts.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Tony David Sprotte <tony@sprotte.dev>",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/qualiti/pi-git-status-line.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/qualiti/pi-git-status-line#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/qualiti/pi-git-status-line/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"pi-package",
|
|
18
|
+
"pi-extension",
|
|
19
|
+
"pi",
|
|
20
|
+
"git",
|
|
21
|
+
"status-line"
|
|
22
|
+
],
|
|
23
|
+
"files": [
|
|
24
|
+
"extensions",
|
|
25
|
+
"README.md",
|
|
26
|
+
"LICENSE"
|
|
27
|
+
],
|
|
28
|
+
"scripts": {
|
|
29
|
+
"build": "echo 'No build step required'",
|
|
30
|
+
"test": "vitest run",
|
|
31
|
+
"check": "npm test && npm pack --dry-run",
|
|
32
|
+
"pack:check": "npm pack --dry-run"
|
|
33
|
+
},
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"@mariozechner/pi-coding-agent": "*"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"vitest": "^3.2.4"
|
|
39
|
+
},
|
|
40
|
+
"pi": {
|
|
41
|
+
"extensions": [
|
|
42
|
+
"./extensions"
|
|
43
|
+
]
|
|
44
|
+
}
|
|
45
|
+
}
|