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 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
+ }