@bugabinga/pi-ext-git-status 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/CHANGELOG.md +12 -0
- package/README.md +11 -0
- package/assets/footer_suite.gif +0 -0
- package/index.ts +169 -0
- package/package.json +15 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 - 2026-05-21
|
|
4
|
+
|
|
5
|
+
- a40a427 prepare extensions for npm release
|
|
6
|
+
- 6a97080 add asciinema demo workflow
|
|
7
|
+
- 517ba54 pi: add extension footer fallbacks
|
|
8
|
+
- 133cb7d chore(pi): migrate extensions to earendil packages
|
|
9
|
+
- 5ca1296 Rework Pi agent extensions
|
|
10
|
+
- b87a61a feat(pi): monorepo workspace — all extensions are proper packages
|
|
11
|
+
- b408bba pi(ext): footer segment producers (context, timing, cost, runtime, git-status, pr-status, thinking)
|
|
12
|
+
|
package/README.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# git-status
|
|
2
|
+
|
|
3
|
+
Pi workspace git telemetry.
|
|
4
|
+
|
|
5
|
+
Shows cwd, branch, ahead/behind, stash, and dirty-state indicators. Uses `footer:segment` with `@bugabinga/pi-ext-footer`; otherwise falls back to Pi status text.
|
|
6
|
+
|
|
7
|
+
## Demo
|
|
8
|
+
|
|
9
|
+
<!-- demo:footer_suite:start -->
|
|
10
|
+

|
|
11
|
+
<!-- demo:footer_suite:end -->
|
|
Binary file
|
package/index.ts
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git status footer segment producer.
|
|
3
|
+
*
|
|
4
|
+
* Detects git branch, ahead/behind, and file status via porcelain v2.
|
|
5
|
+
* Emits footer:segment with zone "workspace".
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { execFile } from "node:child_process";
|
|
10
|
+
import { promisify } from "node:util";
|
|
11
|
+
|
|
12
|
+
const execFileAsync = promisify(execFile);
|
|
13
|
+
const FOOTER_KEY = Symbol.for("@bugabinga/pi-ext-footer");
|
|
14
|
+
const CWD_STATUS_ID = "cwd";
|
|
15
|
+
const GIT_STATUS_ID = "git";
|
|
16
|
+
|
|
17
|
+
type GitStatus = {
|
|
18
|
+
branch?: string;
|
|
19
|
+
ahead: number;
|
|
20
|
+
behind: number;
|
|
21
|
+
conflicted: number;
|
|
22
|
+
untracked: number;
|
|
23
|
+
stashed: boolean;
|
|
24
|
+
modified: number;
|
|
25
|
+
staged: number;
|
|
26
|
+
renamed: number;
|
|
27
|
+
deleted: number;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const ICONS = {
|
|
31
|
+
git: "", ahead: "↑", behind: "↓", diverged: "⇕",
|
|
32
|
+
conflicted: "=", untracked: "?", stashed: "$",
|
|
33
|
+
modified: "!", staged: "+", renamed: "»", deleted: "✘",
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
function hasFooter(): boolean {
|
|
37
|
+
const footer = (globalThis as any)[FOOTER_KEY];
|
|
38
|
+
return footer?.loaded === true && footer.version >= 1;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function parsePorcelain(stdout: string, hasStash: boolean): GitStatus {
|
|
42
|
+
const s: GitStatus = { branch: undefined, ahead: 0, behind: 0, conflicted: 0, untracked: 0, stashed: false, modified: 0, staged: 0, renamed: 0, deleted: 0 };
|
|
43
|
+
s.stashed = hasStash;
|
|
44
|
+
|
|
45
|
+
for (const line of stdout.split(/\r?\n/)) {
|
|
46
|
+
if (!line) continue;
|
|
47
|
+
if (line.startsWith("# branch.head ")) {
|
|
48
|
+
const b = line.slice("# branch.head ".length).trim();
|
|
49
|
+
s.branch = b && b !== "(detached)" ? b : undefined;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (line.startsWith("# branch.ab ")) {
|
|
53
|
+
const m = line.match(/\+(\d+)\s+-(\d+)/);
|
|
54
|
+
if (m) { s.ahead = Number(m[1]); s.behind = Number(m[2]); }
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (line.startsWith("#")) continue;
|
|
58
|
+
if (line.startsWith("? ")) { s.untracked++; continue; }
|
|
59
|
+
if (line.startsWith("u ")) { s.conflicted++; continue; }
|
|
60
|
+
if (!(line.startsWith("1 ") || line.startsWith("2 "))) continue;
|
|
61
|
+
const xy = line.split(" ")[1] ?? "..";
|
|
62
|
+
const x = xy[0] ?? ".", y = xy[1] ?? ".";
|
|
63
|
+
if (x === "R") s.renamed++;
|
|
64
|
+
else if (x === "D") s.deleted++;
|
|
65
|
+
else if (x !== "." && x !== " ") s.staged++;
|
|
66
|
+
if (y === "M") s.modified++;
|
|
67
|
+
else if (y === "D") s.deleted++;
|
|
68
|
+
}
|
|
69
|
+
return s;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function readGitStatus(cwd: string): Promise<GitStatus> {
|
|
73
|
+
const empty: GitStatus = { branch: undefined, ahead: 0, behind: 0, conflicted: 0, untracked: 0, stashed: false, modified: 0, staged: 0, renamed: 0, deleted: 0 };
|
|
74
|
+
try {
|
|
75
|
+
const [{ stdout }, stash] = await Promise.all([
|
|
76
|
+
execFileAsync("git", ["status", "--porcelain=2", "--branch"], { cwd }),
|
|
77
|
+
execFileAsync("git", ["rev-parse", "--verify", "--quiet", "refs/stash"], { cwd }).catch(() => ({ stdout: "" })),
|
|
78
|
+
]);
|
|
79
|
+
return parsePorcelain(
|
|
80
|
+
typeof stdout === "string" ? stdout : String(stdout),
|
|
81
|
+
stash.stdout.trim().length > 0,
|
|
82
|
+
);
|
|
83
|
+
} catch { return empty; }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function gitLabel(status: GitStatus): string | undefined {
|
|
87
|
+
if (!status.branch) return undefined;
|
|
88
|
+
const allStatus = [
|
|
89
|
+
status.conflicted > 0 ? ICONS.conflicted : "",
|
|
90
|
+
status.stashed ? ICONS.stashed : "",
|
|
91
|
+
status.deleted > 0 ? ICONS.deleted : "",
|
|
92
|
+
status.renamed > 0 ? ICONS.renamed : "",
|
|
93
|
+
status.modified > 0 ? ICONS.modified : "",
|
|
94
|
+
status.staged > 0 ? ICONS.staged : "",
|
|
95
|
+
status.untracked > 0 ? ICONS.untracked : "",
|
|
96
|
+
].join("");
|
|
97
|
+
const ab = status.ahead > 0 && status.behind > 0 ? ICONS.diverged
|
|
98
|
+
: status.ahead > 0 ? ICONS.ahead
|
|
99
|
+
: status.behind > 0 ? ICONS.behind : "";
|
|
100
|
+
const statusBlock = allStatus || ab ? `[${allStatus}${ab}]` : "";
|
|
101
|
+
return `${status.branch}${statusBlock ? ` ${statusBlock}` : ""}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function clearSegments(pi: ExtensionAPI) {
|
|
105
|
+
pi.events.emit("footer:segment", { id: "cwd", text: undefined });
|
|
106
|
+
pi.events.emit("footer:segment", { id: "git-branch", text: undefined });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function emitGit(pi: ExtensionAPI, ctx: ExtensionContext, status: GitStatus) {
|
|
110
|
+
const home = process.env.HOME;
|
|
111
|
+
const path = home && ctx.cwd.startsWith(home) ? `~${ctx.cwd.slice(home.length)}` : ctx.cwd;
|
|
112
|
+
const branch = gitLabel(status);
|
|
113
|
+
|
|
114
|
+
if (hasFooter()) {
|
|
115
|
+
ctx.ui.setStatus(CWD_STATUS_ID, undefined);
|
|
116
|
+
ctx.ui.setStatus(GIT_STATUS_ID, undefined);
|
|
117
|
+
pi.events.emit("footer:segment", { id: "cwd", text: path, icon: "", color: "syntaxOperator", zone: "workspace", order: 0 });
|
|
118
|
+
if (branch) {
|
|
119
|
+
pi.events.emit("footer:segment", {
|
|
120
|
+
id: "git-branch",
|
|
121
|
+
text: branch,
|
|
122
|
+
icon: ICONS.git,
|
|
123
|
+
color: "syntaxKeyword",
|
|
124
|
+
zone: "workspace",
|
|
125
|
+
order: 1,
|
|
126
|
+
});
|
|
127
|
+
} else {
|
|
128
|
+
pi.events.emit("footer:segment", { id: "git-branch", text: undefined });
|
|
129
|
+
}
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
clearSegments(pi);
|
|
134
|
+
ctx.ui.setStatus(CWD_STATUS_ID, path);
|
|
135
|
+
ctx.ui.setStatus(GIT_STATUS_ID, branch);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export default function gitStatusExtension(pi: ExtensionAPI) {
|
|
139
|
+
let currentCwd = "";
|
|
140
|
+
let currentCtx: ExtensionContext | undefined;
|
|
141
|
+
let hasUI = false;
|
|
142
|
+
let refreshInFlight = false;
|
|
143
|
+
let refreshPending = false;
|
|
144
|
+
|
|
145
|
+
const refresh = async () => {
|
|
146
|
+
if (!hasUI || !currentCtx || refreshInFlight) { refreshPending = true; return; }
|
|
147
|
+
const ctx = currentCtx;
|
|
148
|
+
refreshInFlight = true;
|
|
149
|
+
try {
|
|
150
|
+
const status = await readGitStatus(currentCwd);
|
|
151
|
+
emitGit(pi, ctx, status);
|
|
152
|
+
} finally {
|
|
153
|
+
refreshInFlight = false;
|
|
154
|
+
if (refreshPending) { refreshPending = false; refresh(); }
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
pi.on("session_start", async (_e, ctx) => { hasUI = ctx.hasUI; currentCwd = ctx.cwd; currentCtx = ctx; refresh(); });
|
|
159
|
+
pi.on("agent_end", async () => { if (hasUI) refresh(); });
|
|
160
|
+
pi.on("tool_execution_end", async () => { if (hasUI) refresh(); });
|
|
161
|
+
pi.on("session_shutdown", async (_e, ctx) => {
|
|
162
|
+
clearSegments(pi);
|
|
163
|
+
if (ctx.hasUI) {
|
|
164
|
+
ctx.ui.setStatus(CWD_STATUS_ID, undefined);
|
|
165
|
+
ctx.ui.setStatus(GIT_STATUS_ID, undefined);
|
|
166
|
+
}
|
|
167
|
+
currentCtx = undefined;
|
|
168
|
+
});
|
|
169
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@bugabinga/pi-ext-git-status",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "index.ts",
|
|
6
|
+
"peerDependencies": {
|
|
7
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
8
|
+
},
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"description": "Git workspace telemetry for Pi.",
|
|
11
|
+
"keywords": [
|
|
12
|
+
"pi",
|
|
13
|
+
"pi-extension"
|
|
14
|
+
]
|
|
15
|
+
}
|