@saadjs/pi-git-status 0.1.0 → 0.2.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 +7 -1
- package/core.ts +73 -2
- package/index.ts +158 -15
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
Git status in Pi's footer, alongside other extensions' statuses.
|
|
4
4
|
|
|
5
5
|
```text
|
|
6
|
-
|
|
6
|
+
feat • #3 • 3 changed • 2 untracked • +42 −17
|
|
7
7
|
```
|
|
8
8
|
|
|
9
9
|
Clean trees show `main • clean`. The status is hidden outside Git repositories or when Git is unavailable.
|
|
@@ -31,6 +31,12 @@ Use an absolute path to `index.ts` when running Pi in another repository.
|
|
|
31
31
|
|
|
32
32
|
Counts cover the whole repository, even from a subdirectory. Refreshes on session start, after successful `edit`/`write` tools, and after agent completion.
|
|
33
33
|
|
|
34
|
+
## Pull request
|
|
35
|
+
|
|
36
|
+
`#3` is the GitHub PR for the current branch, found with `gh pr view`. It's a clickable link (OSC 8) in terminals that support them. Colors: green for open, muted for draft, accent for merged, red for closed. It's hidden on `main`/`master`, when there is no PR, and when `gh` is missing or signed out.
|
|
37
|
+
|
|
38
|
+
The PR is looked up when the session starts, when the branch changes, and after `git push`, `git checkout`, `git switch`, or `gh pr` commands. Between those, it's polled every 90s while you're active. Polling slows when you go idle, backs off when no PR is found, and stops after an hour without activity.
|
|
39
|
+
|
|
34
40
|
Custom footers must display extension statuses via `footerData.getExtensionStatuses()`.
|
|
35
41
|
|
|
36
42
|
## Test
|
package/core.ts
CHANGED
|
@@ -118,18 +118,89 @@ export async function readGitStatus(cwd: string, signal?: AbortSignal): Promise<
|
|
|
118
118
|
}
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
export type PullRequestState = "open" | "draft" | "merged" | "closed";
|
|
122
|
+
|
|
123
|
+
export interface PullRequest {
|
|
124
|
+
number: number;
|
|
125
|
+
url: string;
|
|
126
|
+
state: PullRequestState;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** `null` means the branch has no PR; `"unavailable"` means gh is missing or signed out. */
|
|
130
|
+
export type PullRequestLookup = PullRequest | null | "unavailable";
|
|
131
|
+
|
|
132
|
+
export function parsePullRequest(output: string): PullRequest | null {
|
|
133
|
+
try {
|
|
134
|
+
const data: unknown = JSON.parse(output);
|
|
135
|
+
if (typeof data !== "object" || data === null) return null;
|
|
136
|
+
const { number, url, state, isDraft } = data as Record<string, unknown>;
|
|
137
|
+
if (typeof number !== "number" || typeof url !== "string" || !url.startsWith("https://")) {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
if (state === "OPEN") return { number, url, state: isDraft === true ? "draft" : "open" };
|
|
141
|
+
if (state === "MERGED") return { number, url, state: "merged" };
|
|
142
|
+
if (state === "CLOSED") return { number, url, state: "closed" };
|
|
143
|
+
return null;
|
|
144
|
+
} catch {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Looks up the PR for the checked-out branch, including merged and closed ones. */
|
|
150
|
+
export function readPullRequest(cwd: string, signal?: AbortSignal): Promise<PullRequestLookup> {
|
|
151
|
+
return new Promise((resolve, reject) => {
|
|
152
|
+
execFile(
|
|
153
|
+
"gh",
|
|
154
|
+
["pr", "view", "--json", "number,url,state,isDraft"],
|
|
155
|
+
{
|
|
156
|
+
cwd,
|
|
157
|
+
signal,
|
|
158
|
+
env: { ...process.env, GH_PROMPT_DISABLED: "1", NO_COLOR: "1" },
|
|
159
|
+
encoding: "utf8",
|
|
160
|
+
timeout: 10_000,
|
|
161
|
+
windowsHide: true,
|
|
162
|
+
},
|
|
163
|
+
(error, stdout, stderr) => {
|
|
164
|
+
if (!error) return resolve(parsePullRequest(stdout));
|
|
165
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return resolve("unavailable");
|
|
166
|
+
if (/no (pull requests|open pull requests) found/i.test(stderr)) return resolve(null);
|
|
167
|
+
if (/gh auth login|authentication|bad credentials/i.test(stderr)) {
|
|
168
|
+
return resolve("unavailable");
|
|
169
|
+
}
|
|
170
|
+
// Network errors, rate limits, non-GitHub remotes: let the caller keep the last result.
|
|
171
|
+
reject(error);
|
|
172
|
+
},
|
|
173
|
+
);
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** OSC 8 terminal hyperlink. */
|
|
178
|
+
export function hyperlink(url: string, text: string): string {
|
|
179
|
+
return `\x1b]8;;${url}\x07${text}\x1b]8;;\x07`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const PR_COLORS: Record<PullRequestState, StatusColor> = {
|
|
183
|
+
open: "success",
|
|
184
|
+
draft: "muted",
|
|
185
|
+
merged: "accent",
|
|
186
|
+
closed: "error",
|
|
187
|
+
};
|
|
188
|
+
|
|
121
189
|
type StatusColor = "text" | "accent" | "muted" | "success" | "error";
|
|
122
190
|
|
|
123
191
|
export function formatGitStatus(
|
|
124
192
|
status: GitStatus,
|
|
125
193
|
color: (name: StatusColor, text: string) => string = (_name, text) => text,
|
|
194
|
+
pr?: PullRequest | null,
|
|
126
195
|
): string {
|
|
127
196
|
const separator = color("muted", " • ");
|
|
197
|
+
const head = [color("text", status.branch)];
|
|
198
|
+
if (pr) head.push(hyperlink(pr.url, color(PR_COLORS[pr.state], `#${pr.number}`)));
|
|
128
199
|
if (status.changed === 0 && status.untracked === 0) {
|
|
129
|
-
return
|
|
200
|
+
return [...head, color("success", "clean")].join(separator);
|
|
130
201
|
}
|
|
131
202
|
return [
|
|
132
|
-
|
|
203
|
+
...head,
|
|
133
204
|
color(status.changed ? "accent" : "muted", `${status.changed} changed`),
|
|
134
205
|
color("muted", `${status.untracked} untracked`),
|
|
135
206
|
`${color(status.added ? "success" : "muted", `+${status.added}`)} ${color(status.removed ? "error" : "muted", `−${status.removed}`)}`,
|
package/index.ts
CHANGED
|
@@ -1,17 +1,43 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
formatGitStatus,
|
|
4
|
+
readGitStatus,
|
|
5
|
+
readPullRequest,
|
|
6
|
+
type GitStatus,
|
|
7
|
+
type PullRequest,
|
|
8
|
+
} from "./core.ts";
|
|
3
9
|
|
|
4
10
|
const STATUS_KEY = "git-status";
|
|
5
11
|
const DEBOUNCE_MS = 150;
|
|
12
|
+
const SECOND = 1_000;
|
|
13
|
+
const MINUTE = 60 * SECOND;
|
|
14
|
+
// Same cadence as Claude Code's PR footer: slow down as the user goes idle, stop after an hour.
|
|
15
|
+
const PR_IDLE_STOP_MS = 60 * MINUTE;
|
|
16
|
+
const PR_SKIP_BRANCHES = new Set(["detached", "main", "master"]);
|
|
17
|
+
const PR_COMMAND = /\b(?:git\s+(?:push|checkout|switch)|gh\s+pr)\b/;
|
|
18
|
+
|
|
19
|
+
function idleInterval(idle: number) {
|
|
20
|
+
if (idle < 30 * SECOND) return 90 * SECOND;
|
|
21
|
+
if (idle < 5 * MINUTE) return 3 * MINUTE;
|
|
22
|
+
if (idle < 30 * MINUTE) return 10 * MINUTE;
|
|
23
|
+
return 30 * MINUTE;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Backoff after lookups that found no PR or failed. */
|
|
27
|
+
function missInterval(misses: number) {
|
|
28
|
+
return [90 * SECOND, 5 * MINUTE, 15 * MINUTE][misses] ?? 30 * MINUTE;
|
|
29
|
+
}
|
|
6
30
|
|
|
7
31
|
export default function (pi: ExtensionAPI) {
|
|
8
32
|
let stop: (() => void) | undefined;
|
|
9
|
-
let requestRefresh: ((ctx: ExtensionContext) => void) | undefined;
|
|
33
|
+
let requestRefresh: ((ctx: ExtensionContext, options?: { pr?: boolean }) => void) | undefined;
|
|
34
|
+
let markActive: (() => void) | undefined;
|
|
10
35
|
|
|
11
36
|
pi.on("session_start", (_event, ctx) => {
|
|
12
37
|
stop?.();
|
|
13
38
|
stop = undefined;
|
|
14
39
|
requestRefresh = undefined;
|
|
40
|
+
markActive = undefined;
|
|
15
41
|
if (ctx.mode !== "tui" || !ctx.hasUI) return;
|
|
16
42
|
|
|
17
43
|
let context = ctx;
|
|
@@ -22,6 +48,91 @@ export default function (pi: ExtensionAPI) {
|
|
|
22
48
|
let debounce: ReturnType<typeof setTimeout> | undefined;
|
|
23
49
|
const controller = new AbortController();
|
|
24
50
|
|
|
51
|
+
let git: GitStatus | null = null;
|
|
52
|
+
let pr: PullRequest | null = null;
|
|
53
|
+
let prBranch: string | undefined;
|
|
54
|
+
let prUnavailable = false;
|
|
55
|
+
let prBusy = false;
|
|
56
|
+
let prPending = false;
|
|
57
|
+
let prForced = false;
|
|
58
|
+
let prTimer: ReturnType<typeof setTimeout> | undefined;
|
|
59
|
+
let lastPrFetch = 0;
|
|
60
|
+
let prMisses = 0;
|
|
61
|
+
let lastActivity = Date.now();
|
|
62
|
+
|
|
63
|
+
function render() {
|
|
64
|
+
const current = context;
|
|
65
|
+
const text = git
|
|
66
|
+
? formatGitStatus(
|
|
67
|
+
git,
|
|
68
|
+
(name, value) => current.ui.theme.fg(name, value),
|
|
69
|
+
prBranch === git.branch ? pr : null,
|
|
70
|
+
)
|
|
71
|
+
: undefined;
|
|
72
|
+
if (text !== lastText) {
|
|
73
|
+
current.ui.setStatus(STATUS_KEY, text);
|
|
74
|
+
lastText = text;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function prEligible() {
|
|
79
|
+
return !disposed && !prUnavailable && git !== null && !PR_SKIP_BRANCHES.has(git.branch);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function schedulePr() {
|
|
83
|
+
if (prTimer) clearTimeout(prTimer);
|
|
84
|
+
prTimer = undefined;
|
|
85
|
+
if (!prEligible()) return;
|
|
86
|
+
const now = Date.now();
|
|
87
|
+
if (now - lastActivity >= PR_IDLE_STOP_MS) return;
|
|
88
|
+
const interval = Math.max(idleInterval(now - lastActivity), missInterval(prMisses));
|
|
89
|
+
prTimer = setTimeout(
|
|
90
|
+
() => {
|
|
91
|
+
prTimer = undefined;
|
|
92
|
+
if (Date.now() - lastActivity < PR_IDLE_STOP_MS) void fetchPr();
|
|
93
|
+
},
|
|
94
|
+
Math.max(0, lastPrFetch + interval - now),
|
|
95
|
+
);
|
|
96
|
+
prTimer.unref();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function fetchPr() {
|
|
100
|
+
if (!prEligible()) return;
|
|
101
|
+
if (prBusy) {
|
|
102
|
+
prPending = true;
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (prTimer) clearTimeout(prTimer);
|
|
106
|
+
prTimer = undefined;
|
|
107
|
+
prBusy = true;
|
|
108
|
+
const branch = git!.branch;
|
|
109
|
+
try {
|
|
110
|
+
const result = await readPullRequest(context.cwd, controller.signal).catch(
|
|
111
|
+
() => "error" as const,
|
|
112
|
+
);
|
|
113
|
+
if (disposed || git?.branch !== branch) return;
|
|
114
|
+
lastPrFetch = Date.now();
|
|
115
|
+
if (result === "unavailable") {
|
|
116
|
+
prUnavailable = true;
|
|
117
|
+
pr = null;
|
|
118
|
+
} else if (result === "error") {
|
|
119
|
+
prMisses++;
|
|
120
|
+
} else {
|
|
121
|
+
pr = result;
|
|
122
|
+
prMisses = result ? 0 : prMisses + 1;
|
|
123
|
+
}
|
|
124
|
+
render();
|
|
125
|
+
} finally {
|
|
126
|
+
prBusy = false;
|
|
127
|
+
if (prPending && !disposed) {
|
|
128
|
+
prPending = false;
|
|
129
|
+
void fetchPr();
|
|
130
|
+
} else {
|
|
131
|
+
schedulePr();
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
25
136
|
async function refresh() {
|
|
26
137
|
if (disposed) return;
|
|
27
138
|
if (busy) {
|
|
@@ -29,16 +140,23 @@ export default function (pi: ExtensionAPI) {
|
|
|
29
140
|
return;
|
|
30
141
|
}
|
|
31
142
|
busy = true;
|
|
32
|
-
const current = context;
|
|
33
143
|
try {
|
|
34
|
-
const status = await readGitStatus(
|
|
144
|
+
const status = await readGitStatus(context.cwd, controller.signal);
|
|
35
145
|
if (disposed) return;
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
if (
|
|
40
|
-
|
|
41
|
-
|
|
146
|
+
git = status;
|
|
147
|
+
let fetch = prForced;
|
|
148
|
+
prForced = false;
|
|
149
|
+
if (status && status.branch !== prBranch) {
|
|
150
|
+
prBranch = status.branch;
|
|
151
|
+
pr = null;
|
|
152
|
+
prMisses = 0;
|
|
153
|
+
fetch = true;
|
|
154
|
+
}
|
|
155
|
+
render();
|
|
156
|
+
if (fetch) void fetchPr();
|
|
157
|
+
else if (!status && prTimer) {
|
|
158
|
+
clearTimeout(prTimer);
|
|
159
|
+
prTimer = undefined;
|
|
42
160
|
}
|
|
43
161
|
} finally {
|
|
44
162
|
busy = false;
|
|
@@ -49,8 +167,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
49
167
|
}
|
|
50
168
|
}
|
|
51
169
|
|
|
52
|
-
requestRefresh = (nextContext) => {
|
|
170
|
+
requestRefresh = (nextContext, options) => {
|
|
53
171
|
context = nextContext;
|
|
172
|
+
if (options?.pr) {
|
|
173
|
+
prForced = true;
|
|
174
|
+
prMisses = 0;
|
|
175
|
+
}
|
|
54
176
|
if (debounce) clearTimeout(debounce);
|
|
55
177
|
debounce = setTimeout(() => {
|
|
56
178
|
debounce = undefined;
|
|
@@ -59,28 +181,49 @@ export default function (pi: ExtensionAPI) {
|
|
|
59
181
|
debounce.unref();
|
|
60
182
|
};
|
|
61
183
|
|
|
184
|
+
markActive = () => {
|
|
185
|
+
const wasIdle = Date.now() - lastActivity >= PR_IDLE_STOP_MS;
|
|
186
|
+
lastActivity = Date.now();
|
|
187
|
+
// Polling stops after an hour idle; resume it, fetching right away if overdue.
|
|
188
|
+
if (wasIdle || (!prTimer && !prBusy)) schedulePr();
|
|
189
|
+
};
|
|
190
|
+
|
|
62
191
|
stop = () => {
|
|
63
192
|
if (disposed) return;
|
|
64
193
|
disposed = true;
|
|
65
194
|
if (debounce) clearTimeout(debounce);
|
|
195
|
+
if (prTimer) clearTimeout(prTimer);
|
|
66
196
|
controller.abort();
|
|
67
197
|
context.ui.setStatus(STATUS_KEY, undefined);
|
|
68
198
|
};
|
|
69
199
|
void refresh();
|
|
70
200
|
});
|
|
71
201
|
|
|
72
|
-
|
|
202
|
+
pi.on("input", () => {
|
|
203
|
+
markActive?.();
|
|
204
|
+
});
|
|
205
|
+
|
|
73
206
|
pi.on("tool_result", (event, ctx) => {
|
|
74
|
-
|
|
75
|
-
|
|
207
|
+
markActive?.();
|
|
208
|
+
if (event.isError) return;
|
|
209
|
+
// Refresh after successful file mutations, not read-only tools or every shell call.
|
|
210
|
+
if (event.toolName === "edit" || event.toolName === "write") requestRefresh?.(ctx);
|
|
211
|
+
// Pushes, checkouts, and gh pr commands can create a PR or change its state.
|
|
212
|
+
const command = (event.input as { command?: unknown }).command;
|
|
213
|
+
if (event.toolName === "bash" && typeof command === "string" && PR_COMMAND.test(command)) {
|
|
214
|
+
requestRefresh?.(ctx, { pr: true });
|
|
76
215
|
}
|
|
77
216
|
});
|
|
78
217
|
// Catch shell-made changes once the agent finishes.
|
|
79
|
-
pi.on("agent_end", (_event, ctx) =>
|
|
218
|
+
pi.on("agent_end", (_event, ctx) => {
|
|
219
|
+
markActive?.();
|
|
220
|
+
requestRefresh?.(ctx);
|
|
221
|
+
});
|
|
80
222
|
|
|
81
223
|
pi.on("session_shutdown", () => {
|
|
82
224
|
stop?.();
|
|
83
225
|
stop = undefined;
|
|
84
226
|
requestRefresh = undefined;
|
|
227
|
+
markActive = undefined;
|
|
85
228
|
});
|
|
86
229
|
}
|