@diegopetrucci/pi-extensions 0.1.74 → 0.1.78
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 +2 -0
- package/README.md +17 -12
- package/extensions/agent-workflow-audit/README.md +1 -1
- package/extensions/agent-workflow-audit/index.ts +1631 -1009
- package/extensions/agent-workflow-audit/package.json +1 -1
- package/extensions/annotate-git-diff/README.md +3 -1
- package/extensions/annotate-git-diff/clipboard.ts +109 -100
- package/extensions/annotate-git-diff/git.ts +846 -768
- package/extensions/annotate-git-diff/glimpseui.d.ts +87 -78
- package/extensions/annotate-git-diff/index.ts +712 -671
- package/extensions/annotate-git-diff/package.json +1 -1
- package/extensions/annotate-git-diff/prompt.ts +45 -45
- package/extensions/annotate-git-diff/quiet-glimpse.ts +161 -155
- package/extensions/annotate-git-diff/review-server.ts +337 -311
- package/extensions/annotate-git-diff/types.ts +132 -132
- package/extensions/annotate-git-diff/ui.ts +27 -27
- package/extensions/annotate-git-diff/watch.ts +74 -74
- package/extensions/annotate-git-diff/web/app.js +1947 -1884
- package/extensions/annotate-git-diff/web/index.html +5 -6
- package/extensions/annotate-last-message/README.md +1 -1
- package/extensions/annotate-last-message/glimpseui.d.ts +87 -78
- package/extensions/annotate-last-message/index.ts +210 -206
- package/extensions/annotate-last-message/package.json +1 -1
- package/extensions/annotate-last-message/prompt.ts +87 -66
- package/extensions/annotate-last-message/quiet-glimpse.ts +161 -155
- package/extensions/annotate-last-message/session.ts +94 -89
- package/extensions/annotate-last-message/theme.ts +199 -193
- package/extensions/annotate-last-message/types.ts +23 -23
- package/extensions/annotate-last-message/ui.ts +27 -25
- package/extensions/annotate-last-message/web/app.js +4 -7
- package/extensions/annotate-last-message/web/index.html +3 -3
- package/extensions/annotate-last-message/web/md-renderer.js +1 -1
- package/extensions/brrr/README.md +1 -1
- package/extensions/brrr/index.ts +213 -191
- package/extensions/brrr/package.json +1 -1
- package/extensions/claude-fast/README.md +5 -79
- package/extensions/claude-fast/index.ts +31 -289
- package/extensions/claude-fast/package.json +2 -3
- package/extensions/code-reviewer/README.md +6 -6
- package/extensions/code-reviewer/index.ts +1825 -1373
- package/extensions/code-reviewer/package.json +1 -1
- package/extensions/confirm-destructive/README.md +1 -1
- package/extensions/confirm-destructive/index.ts +50 -52
- package/extensions/confirm-destructive/package.json +1 -1
- package/extensions/context-cap/README.md +9 -9
- package/extensions/context-cap/index.ts +313 -305
- package/extensions/context-cap/package.json +2 -2
- package/extensions/context-inspector/index.ts +1211 -1064
- package/extensions/context-inspector/package.json +1 -1
- package/extensions/contrarian/README.md +4 -3
- package/extensions/contrarian/index.ts +1863 -1737
- package/extensions/contrarian/package.json +1 -1
- package/extensions/dirty-repo-guard/index.ts +37 -41
- package/extensions/dirty-repo-guard/package.json +1 -1
- package/extensions/dynamic-context-pruning/README.md +11 -43
- package/extensions/dynamic-context-pruning/index.ts +2131 -2105
- package/extensions/dynamic-context-pruning/package.json +1 -1
- package/extensions/fast/README.md +52 -14
- package/extensions/fast/index.ts +513 -317
- package/extensions/fast/package.json +1 -1
- package/extensions/git-footer/README.md +1 -1
- package/extensions/git-footer/index.ts +458 -411
- package/extensions/git-footer/package.json +2 -3
- package/extensions/gnosis/index.ts +180 -130
- package/extensions/gnosis/package.json +1 -1
- package/extensions/inline-bash/index.ts +87 -103
- package/extensions/inline-bash/package.json +1 -1
- package/extensions/librarian/README.md +1 -1
- package/extensions/librarian/index.ts +1877 -1419
- package/extensions/librarian/package.json +1 -1
- package/extensions/minimal-footer/README.md +5 -5
- package/extensions/minimal-footer/index.ts +987 -1012
- package/extensions/minimal-footer/minimal-footer.example.json +1 -1
- package/extensions/minimal-footer/openai-usage.ts +195 -190
- package/extensions/minimal-footer/package.json +1 -1
- package/extensions/notify/index.ts +222 -230
- package/extensions/notify/package.json +1 -1
- package/extensions/openai-fast/README.md +5 -73
- package/extensions/openai-fast/index.ts +32 -272
- package/extensions/openai-fast/package.json +2 -3
- package/extensions/oracle/README.md +4 -3
- package/extensions/oracle/index.ts +1740 -1627
- package/extensions/oracle/package.json +1 -1
- package/extensions/permission-gate/README.md +3 -0
- package/extensions/permission-gate/index.ts +785 -612
- package/extensions/permission-gate/package.json +1 -1
- package/extensions/permission-gate/powershell-safety.ts +840 -793
- package/extensions/project-mcp-json/.pi-fleet-tested-version +1 -0
- package/extensions/project-mcp-json/README.md +199 -0
- package/extensions/project-mcp-json/config.ts +564 -0
- package/extensions/project-mcp-json/index.ts +119 -0
- package/extensions/project-mcp-json/package.json +38 -0
- package/extensions/quiet-tools/.pi-fleet-tested-version +1 -1
- package/extensions/quiet-tools/README.md +30 -19
- package/extensions/quiet-tools/index.ts +376 -291
- package/extensions/quiet-tools/package.json +2 -2
- package/extensions/review/README.md +2 -2
- package/extensions/review/index.ts +1745 -1872
- package/extensions/review/package.json +1 -1
- package/extensions/todo/index.ts +280 -280
- package/extensions/todo/package.json +1 -1
- package/extensions/triage-comments/README.md +6 -4
- package/extensions/triage-comments/index.ts +2620 -1888
- package/extensions/triage-comments/package.json +1 -1
- package/package.json +9 -6
- package/assets/social-preview.png +0 -0
- package/extensions/claude-fast/claude-fast.example.json +0 -4
- package/extensions/openai-fast/openai-fast.example.json +0 -4
|
@@ -2,55 +2,61 @@ import { spawn } from "node:child_process";
|
|
|
2
2
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
|
|
4
4
|
type GitStatusSnapshot = {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
5
|
+
branch?: string;
|
|
6
|
+
staged: number;
|
|
7
|
+
unstaged: number;
|
|
8
|
+
untracked: number;
|
|
9
|
+
conflict: number;
|
|
10
|
+
ahead: number;
|
|
11
|
+
behind: number;
|
|
12
12
|
};
|
|
13
13
|
|
|
14
14
|
type PullRequestSnapshot = {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
15
|
+
number?: number | string;
|
|
16
|
+
state?: string;
|
|
17
|
+
isDraft?: boolean;
|
|
18
|
+
url?: string;
|
|
19
|
+
title?: string;
|
|
20
20
|
};
|
|
21
21
|
|
|
22
22
|
type CommandResult = {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
stdout: string;
|
|
24
|
+
stderr: string;
|
|
25
|
+
exitCode: number | null;
|
|
26
26
|
};
|
|
27
27
|
|
|
28
28
|
type CommandRunner = (
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
29
|
+
command: string,
|
|
30
|
+
args: readonly string[],
|
|
31
|
+
options: { cwd: string; signal: AbortSignal },
|
|
32
32
|
) => Promise<CommandResult>;
|
|
33
33
|
|
|
34
34
|
type RunCommandSafelyResult =
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
| { kind: "ok"; result: CommandResult }
|
|
36
|
+
| { kind: "transient" }
|
|
37
|
+
| { kind: "unavailable" };
|
|
38
38
|
|
|
39
39
|
type TimerHandle = unknown;
|
|
40
40
|
|
|
41
41
|
type Clock = {
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
setInterval(callback: () => void, ms: number): TimerHandle;
|
|
43
|
+
clearInterval(handle: TimerHandle): void;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
type ProjectGitContext = {
|
|
47
|
+
cwd: string;
|
|
48
|
+
isProjectTrusted?: () => boolean;
|
|
44
49
|
};
|
|
45
50
|
|
|
46
51
|
type GitFooterCacheOptions = {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
52
|
+
cwd: () => string;
|
|
53
|
+
canRun?: () => boolean;
|
|
54
|
+
runner?: CommandRunner;
|
|
55
|
+
clock?: Clock;
|
|
56
|
+
refreshIntervalMs?: number;
|
|
57
|
+
gitTimeoutMs?: number;
|
|
58
|
+
ghTimeoutMs?: number;
|
|
59
|
+
onChange?: () => void;
|
|
54
60
|
};
|
|
55
61
|
|
|
56
62
|
const BRANCH_HEAD_PREFIX = "# branch.head ";
|
|
@@ -60,449 +66,490 @@ const STATUS_SEPARATOR = " • ";
|
|
|
60
66
|
const DEFAULT_REFRESH_INTERVAL_MS = 8_000;
|
|
61
67
|
const DEFAULT_GIT_TIMEOUT_MS = 1_500;
|
|
62
68
|
const DEFAULT_GH_TIMEOUT_MS = 3_000;
|
|
63
|
-
const GIT_STATUS_ARGS = [
|
|
69
|
+
const GIT_STATUS_ARGS = [
|
|
70
|
+
"--no-optional-locks",
|
|
71
|
+
"-c",
|
|
72
|
+
"core.fsmonitor=false",
|
|
73
|
+
"status",
|
|
74
|
+
"--porcelain=v2",
|
|
75
|
+
"--branch",
|
|
76
|
+
] as const;
|
|
64
77
|
const GH_PR_VIEW_ARGS = ["pr", "view", "--json", "number,state,isDraft,url,title"] as const;
|
|
65
78
|
|
|
66
79
|
function createEmptyGitStatus(): GitStatusSnapshot {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
80
|
+
return {
|
|
81
|
+
branch: undefined,
|
|
82
|
+
staged: 0,
|
|
83
|
+
unstaged: 0,
|
|
84
|
+
untracked: 0,
|
|
85
|
+
conflict: 0,
|
|
86
|
+
ahead: 0,
|
|
87
|
+
behind: 0,
|
|
88
|
+
};
|
|
76
89
|
}
|
|
77
90
|
|
|
78
91
|
function positiveCount(value: number): number {
|
|
79
|
-
|
|
92
|
+
return Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0;
|
|
80
93
|
}
|
|
81
94
|
|
|
82
95
|
function addTrackedStatusCounts(status: GitStatusSnapshot, xy: string): void {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
96
|
+
if (xy.length !== 2) return;
|
|
97
|
+
if (xy[0] !== ".") status.staged += 1;
|
|
98
|
+
if (xy[1] !== ".") status.unstaged += 1;
|
|
86
99
|
}
|
|
87
100
|
|
|
88
101
|
function parseBranchAheadBehind(line: string, status: GitStatusSnapshot): void {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
102
|
+
const match = /^# branch\.ab \+(\d+) -(\d+)$/.exec(line);
|
|
103
|
+
if (!match) return;
|
|
104
|
+
status.ahead = Number.parseInt(match[1]!, 10);
|
|
105
|
+
status.behind = Number.parseInt(match[2]!, 10);
|
|
93
106
|
}
|
|
94
107
|
|
|
95
108
|
function normalizeBranchHead(value: string): string {
|
|
96
|
-
|
|
97
|
-
|
|
109
|
+
const branch = value.trim();
|
|
110
|
+
return branch === "(detached)" ? "detached" : branch;
|
|
98
111
|
}
|
|
99
112
|
|
|
100
113
|
function parseGitStatusPorcelainV2(output: string): GitStatusSnapshot {
|
|
101
|
-
|
|
114
|
+
const status = createEmptyGitStatus();
|
|
102
115
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
116
|
+
for (const rawLine of output.split("\n")) {
|
|
117
|
+
const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
|
|
118
|
+
if (!line) continue;
|
|
106
119
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
120
|
+
if (line.startsWith(BRANCH_HEAD_PREFIX)) {
|
|
121
|
+
status.branch = normalizeBranchHead(line.slice(BRANCH_HEAD_PREFIX.length)) || undefined;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
111
124
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
125
|
+
if (line.startsWith(BRANCH_AB_PREFIX)) {
|
|
126
|
+
parseBranchAheadBehind(line, status);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
116
129
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
130
|
+
if (line.startsWith("1 ") || line.startsWith("2 ")) {
|
|
131
|
+
addTrackedStatusCounts(status, line.slice(2, 4));
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
121
134
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
135
|
+
if (line.startsWith("u ")) {
|
|
136
|
+
status.conflict += 1;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
126
139
|
|
|
127
|
-
|
|
128
|
-
|
|
140
|
+
if (line.startsWith("? ")) status.untracked += 1;
|
|
141
|
+
}
|
|
129
142
|
|
|
130
|
-
|
|
143
|
+
return status;
|
|
131
144
|
}
|
|
132
145
|
|
|
133
146
|
function formatGitStatusFooterSegment(status: GitStatusSnapshot | undefined): string | undefined {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
147
|
+
if (!status) return undefined;
|
|
148
|
+
|
|
149
|
+
const parts: string[] = [];
|
|
150
|
+
const indicators: Array<[string, number]> = [
|
|
151
|
+
["!", positiveCount(status.conflict)],
|
|
152
|
+
["+", positiveCount(status.staged)],
|
|
153
|
+
["~", positiveCount(status.unstaged)],
|
|
154
|
+
["?", positiveCount(status.untracked)],
|
|
155
|
+
["↑", positiveCount(status.ahead)],
|
|
156
|
+
["↓", positiveCount(status.behind)],
|
|
157
|
+
];
|
|
158
|
+
|
|
159
|
+
for (const [prefix, count] of indicators) {
|
|
160
|
+
if (count > 0) parts.push(`${prefix}${count}`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return parts.length > 0 ? parts.join(" ") : undefined;
|
|
151
164
|
}
|
|
152
165
|
|
|
153
|
-
function formatPullRequestFooterSegment(
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
166
|
+
function formatPullRequestFooterSegment(
|
|
167
|
+
pullRequest: PullRequestSnapshot | undefined,
|
|
168
|
+
): string | undefined {
|
|
169
|
+
const value = pullRequest?.number;
|
|
170
|
+
if (Number.isSafeInteger(value) && Number(value) > 0) return `PR #${value}`;
|
|
171
|
+
if (typeof value === "string") {
|
|
172
|
+
const trimmed = value.trim();
|
|
173
|
+
if (/^[1-9]\d*$/.test(trimmed)) return `PR #${trimmed}`;
|
|
174
|
+
}
|
|
175
|
+
return undefined;
|
|
161
176
|
}
|
|
162
177
|
|
|
163
178
|
function formatGitFooterStatus(
|
|
164
|
-
|
|
165
|
-
|
|
179
|
+
status: GitStatusSnapshot | undefined,
|
|
180
|
+
pullRequest: PullRequestSnapshot | undefined,
|
|
166
181
|
): string | undefined {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
182
|
+
const parts = [
|
|
183
|
+
formatGitStatusFooterSegment(status),
|
|
184
|
+
formatPullRequestFooterSegment(pullRequest),
|
|
185
|
+
].filter((part): part is string => !!part);
|
|
186
|
+
return parts.length > 0 ? parts.join(STATUS_SEPARATOR) : undefined;
|
|
172
187
|
}
|
|
173
188
|
|
|
174
189
|
function parsePullRequestJson(stdout: string): PullRequestSnapshot | undefined {
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
190
|
+
const trimmed = stdout.trim();
|
|
191
|
+
if (!trimmed) return undefined;
|
|
192
|
+
let parsed: unknown;
|
|
193
|
+
try {
|
|
194
|
+
parsed = JSON.parse(trimmed);
|
|
195
|
+
} catch {
|
|
196
|
+
return undefined;
|
|
197
|
+
}
|
|
198
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
|
|
199
|
+
|
|
200
|
+
const record = parsed as Record<string, unknown>;
|
|
201
|
+
const snapshot: PullRequestSnapshot = {};
|
|
202
|
+
if (typeof record.number === "number" || typeof record.number === "string") {
|
|
203
|
+
snapshot.number = record.number;
|
|
204
|
+
}
|
|
205
|
+
if (typeof record.state === "string") snapshot.state = record.state;
|
|
206
|
+
if (typeof record.isDraft === "boolean") snapshot.isDraft = record.isDraft;
|
|
207
|
+
if (typeof record.url === "string") snapshot.url = record.url;
|
|
208
|
+
if (typeof record.title === "string") snapshot.title = record.title;
|
|
209
|
+
return snapshot;
|
|
195
210
|
}
|
|
196
211
|
|
|
197
212
|
function gitStatusSnapshotsEqual(
|
|
198
|
-
|
|
199
|
-
|
|
213
|
+
left: GitStatusSnapshot | undefined,
|
|
214
|
+
right: GitStatusSnapshot | undefined,
|
|
200
215
|
): boolean {
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
216
|
+
return (
|
|
217
|
+
left?.branch === right?.branch &&
|
|
218
|
+
left?.staged === right?.staged &&
|
|
219
|
+
left?.unstaged === right?.unstaged &&
|
|
220
|
+
left?.untracked === right?.untracked &&
|
|
221
|
+
left?.conflict === right?.conflict &&
|
|
222
|
+
left?.ahead === right?.ahead &&
|
|
223
|
+
left?.behind === right?.behind
|
|
224
|
+
);
|
|
210
225
|
}
|
|
211
226
|
|
|
212
227
|
function pullRequestSnapshotsEqual(
|
|
213
|
-
|
|
214
|
-
|
|
228
|
+
left: PullRequestSnapshot | undefined,
|
|
229
|
+
right: PullRequestSnapshot | undefined,
|
|
215
230
|
): boolean {
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
231
|
+
return (
|
|
232
|
+
left?.number === right?.number &&
|
|
233
|
+
left?.state === right?.state &&
|
|
234
|
+
left?.isDraft === right?.isDraft &&
|
|
235
|
+
left?.url === right?.url &&
|
|
236
|
+
left?.title === right?.title
|
|
237
|
+
);
|
|
223
238
|
}
|
|
224
239
|
|
|
225
240
|
function defaultRunner(
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
241
|
+
command: string,
|
|
242
|
+
args: readonly string[],
|
|
243
|
+
options: { cwd: string; signal: AbortSignal },
|
|
229
244
|
): Promise<CommandResult> {
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
245
|
+
return new Promise((resolve, reject) => {
|
|
246
|
+
let child;
|
|
247
|
+
try {
|
|
248
|
+
child = spawn(command, [...args], {
|
|
249
|
+
cwd: options.cwd,
|
|
250
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
251
|
+
windowsHide: true,
|
|
252
|
+
});
|
|
253
|
+
} catch (error) {
|
|
254
|
+
reject(error);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
let stdout = "";
|
|
259
|
+
let stderr = "";
|
|
260
|
+
let settled = false;
|
|
261
|
+
|
|
262
|
+
const finish = (result: CommandResult | Error) => {
|
|
263
|
+
if (settled) return;
|
|
264
|
+
settled = true;
|
|
265
|
+
options.signal.removeEventListener("abort", onAbort);
|
|
266
|
+
if (result instanceof Error) reject(result);
|
|
267
|
+
else resolve(result);
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
const onAbort = () => {
|
|
271
|
+
try {
|
|
272
|
+
child.kill("SIGTERM");
|
|
273
|
+
} catch {
|
|
274
|
+
// Ignore: process may already be gone.
|
|
275
|
+
}
|
|
276
|
+
finish(new Error("aborted"));
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
child.stdout?.on("data", (chunk: Buffer | string) => {
|
|
280
|
+
stdout += typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
|
281
|
+
});
|
|
282
|
+
child.stderr?.on("data", (chunk: Buffer | string) => {
|
|
283
|
+
stderr += typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
|
284
|
+
});
|
|
285
|
+
child.on("error", finish);
|
|
286
|
+
child.on("close", (code) => finish({ stdout, stderr, exitCode: code }));
|
|
287
|
+
|
|
288
|
+
if (options.signal.aborted) {
|
|
289
|
+
onAbort();
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
options.signal.addEventListener("abort", onAbort, { once: true });
|
|
293
|
+
});
|
|
279
294
|
}
|
|
280
295
|
|
|
281
296
|
function defaultClock(): Clock {
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
297
|
+
return {
|
|
298
|
+
setInterval(callback, ms) {
|
|
299
|
+
const handle = setInterval(callback, ms);
|
|
300
|
+
(handle as { unref?: () => void }).unref?.();
|
|
301
|
+
return handle;
|
|
302
|
+
},
|
|
303
|
+
clearInterval(handle) {
|
|
304
|
+
clearInterval(handle as ReturnType<typeof setInterval>);
|
|
305
|
+
},
|
|
306
|
+
};
|
|
292
307
|
}
|
|
293
308
|
|
|
294
309
|
function isCommandUnavailableError(error: unknown): boolean {
|
|
295
|
-
|
|
310
|
+
return !!error && typeof error === "object" && (error as { code?: unknown }).code === "ENOENT";
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function canRunProjectGit(ctx: ProjectGitContext): boolean {
|
|
314
|
+
return typeof ctx.isProjectTrusted === "function" && ctx.isProjectTrusted();
|
|
296
315
|
}
|
|
297
316
|
|
|
298
317
|
class GitFooterCache {
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
318
|
+
private readonly cwd: () => string;
|
|
319
|
+
private readonly canRun: () => boolean;
|
|
320
|
+
private readonly runner: CommandRunner;
|
|
321
|
+
private readonly clock: Clock;
|
|
322
|
+
private readonly refreshIntervalMs: number;
|
|
323
|
+
private readonly gitTimeoutMs: number;
|
|
324
|
+
private readonly ghTimeoutMs: number;
|
|
325
|
+
private readonly onChange: (() => void) | undefined;
|
|
326
|
+
|
|
327
|
+
private intervalHandle: TimerHandle | undefined;
|
|
328
|
+
private readonly inflightControllers = new Set<AbortController>();
|
|
329
|
+
private disposed = false;
|
|
330
|
+
private refreshInFlight: Promise<void> | undefined;
|
|
331
|
+
private statusSnapshot: GitStatusSnapshot | undefined;
|
|
332
|
+
private pullRequestSnapshot: PullRequestSnapshot | undefined;
|
|
333
|
+
private lastSeenBranch: string | undefined;
|
|
334
|
+
|
|
335
|
+
constructor(options: GitFooterCacheOptions) {
|
|
336
|
+
this.cwd = options.cwd;
|
|
337
|
+
this.canRun = options.canRun ?? (() => true);
|
|
338
|
+
this.runner = options.runner ?? defaultRunner;
|
|
339
|
+
this.clock = options.clock ?? defaultClock();
|
|
340
|
+
this.refreshIntervalMs = options.refreshIntervalMs ?? DEFAULT_REFRESH_INTERVAL_MS;
|
|
341
|
+
this.gitTimeoutMs = options.gitTimeoutMs ?? DEFAULT_GIT_TIMEOUT_MS;
|
|
342
|
+
this.ghTimeoutMs = options.ghTimeoutMs ?? DEFAULT_GH_TIMEOUT_MS;
|
|
343
|
+
this.onChange = options.onChange;
|
|
344
|
+
|
|
345
|
+
this.intervalHandle = this.clock.setInterval(() => {
|
|
346
|
+
void this.refresh();
|
|
347
|
+
}, this.refreshIntervalMs);
|
|
348
|
+
void this.refresh();
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
getStatusSnapshot(): GitStatusSnapshot | undefined {
|
|
352
|
+
return this.statusSnapshot;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
getPullRequestSnapshot(): PullRequestSnapshot | undefined {
|
|
356
|
+
return this.pullRequestSnapshot;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
refresh(): Promise<void> {
|
|
360
|
+
if (this.disposed) return Promise.resolve();
|
|
361
|
+
if (!this.canRun()) {
|
|
362
|
+
const previousStatusSnapshot = this.statusSnapshot;
|
|
363
|
+
const previousPullRequestSnapshot = this.pullRequestSnapshot;
|
|
364
|
+
this.statusSnapshot = undefined;
|
|
365
|
+
this.pullRequestSnapshot = undefined;
|
|
366
|
+
this.lastSeenBranch = undefined;
|
|
367
|
+
this.emitChangeIfSnapshotsChanged(previousStatusSnapshot, previousPullRequestSnapshot);
|
|
368
|
+
return Promise.resolve();
|
|
369
|
+
}
|
|
370
|
+
if (this.refreshInFlight) return this.refreshInFlight;
|
|
371
|
+
const run = this.runRefresh()
|
|
372
|
+
.finally(() => {
|
|
373
|
+
this.refreshInFlight = undefined;
|
|
374
|
+
})
|
|
375
|
+
.catch(() => undefined);
|
|
376
|
+
this.refreshInFlight = run;
|
|
377
|
+
return run;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
private async runRefresh(): Promise<void> {
|
|
381
|
+
const previousStatusSnapshot = this.statusSnapshot;
|
|
382
|
+
const previousPullRequestSnapshot = this.pullRequestSnapshot;
|
|
383
|
+
|
|
384
|
+
const result = await this.fetchGitStatus();
|
|
385
|
+
if (this.disposed) return;
|
|
386
|
+
if (result.kind === "transient") return;
|
|
387
|
+
if (result.kind === "not-a-repo" || result.kind === "unavailable") {
|
|
388
|
+
this.statusSnapshot = undefined;
|
|
389
|
+
this.pullRequestSnapshot = undefined;
|
|
390
|
+
this.lastSeenBranch = undefined;
|
|
391
|
+
this.emitChangeIfSnapshotsChanged(previousStatusSnapshot, previousPullRequestSnapshot);
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const status = result.status;
|
|
396
|
+
this.statusSnapshot = status;
|
|
397
|
+
|
|
398
|
+
const branch = typeof status.branch === "string" ? status.branch : undefined;
|
|
399
|
+
const isValidBranch = !!branch && branch !== "detached";
|
|
400
|
+
const branchChanged = branch !== this.lastSeenBranch;
|
|
401
|
+
|
|
402
|
+
if (branchChanged) this.pullRequestSnapshot = undefined;
|
|
403
|
+
this.lastSeenBranch = branch;
|
|
404
|
+
|
|
405
|
+
if (!isValidBranch) {
|
|
406
|
+
this.pullRequestSnapshot = undefined;
|
|
407
|
+
this.emitChangeIfSnapshotsChanged(previousStatusSnapshot, previousPullRequestSnapshot);
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const pr = await this.fetchPullRequest();
|
|
412
|
+
if (this.disposed) return;
|
|
413
|
+
if (pr.kind === "ok") this.pullRequestSnapshot = pr.pullRequest;
|
|
414
|
+
else if (pr.kind === "not-found" || pr.kind === "unavailable")
|
|
415
|
+
this.pullRequestSnapshot = undefined;
|
|
416
|
+
|
|
417
|
+
this.emitChangeIfSnapshotsChanged(previousStatusSnapshot, previousPullRequestSnapshot);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
private emitChangeIfSnapshotsChanged(
|
|
421
|
+
previousStatusSnapshot: GitStatusSnapshot | undefined,
|
|
422
|
+
previousPullRequestSnapshot: PullRequestSnapshot | undefined,
|
|
423
|
+
): void {
|
|
424
|
+
if (this.disposed) return;
|
|
425
|
+
if (
|
|
426
|
+
gitStatusSnapshotsEqual(previousStatusSnapshot, this.statusSnapshot) &&
|
|
427
|
+
pullRequestSnapshotsEqual(previousPullRequestSnapshot, this.pullRequestSnapshot)
|
|
428
|
+
) {
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
try {
|
|
432
|
+
this.onChange?.();
|
|
433
|
+
} catch {
|
|
434
|
+
// Rendering hooks should not break refreshes.
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
private async fetchGitStatus(): Promise<
|
|
439
|
+
| { kind: "ok"; status: GitStatusSnapshot }
|
|
440
|
+
| { kind: "not-a-repo" }
|
|
441
|
+
| { kind: "transient" }
|
|
442
|
+
| { kind: "unavailable" }
|
|
443
|
+
> {
|
|
444
|
+
const result = await this.runCommandSafely("git", GIT_STATUS_ARGS, this.gitTimeoutMs);
|
|
445
|
+
if (result.kind !== "ok") return result;
|
|
446
|
+
if (result.result.exitCode !== 0) return { kind: "not-a-repo" };
|
|
447
|
+
return { kind: "ok", status: parseGitStatusPorcelainV2(result.result.stdout) };
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
private async fetchPullRequest(): Promise<
|
|
451
|
+
| { kind: "ok"; pullRequest: PullRequestSnapshot | undefined }
|
|
452
|
+
| { kind: "not-found" }
|
|
453
|
+
| { kind: "transient" }
|
|
454
|
+
| { kind: "unavailable" }
|
|
455
|
+
> {
|
|
456
|
+
const result = await this.runCommandSafely("gh", GH_PR_VIEW_ARGS, this.ghTimeoutMs);
|
|
457
|
+
if (result.kind !== "ok") return result;
|
|
458
|
+
if (result.result.exitCode !== 0) return { kind: "not-found" };
|
|
459
|
+
return { kind: "ok", pullRequest: parsePullRequestJson(result.result.stdout) };
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
private async runCommandSafely(
|
|
463
|
+
command: string,
|
|
464
|
+
args: readonly string[],
|
|
465
|
+
timeoutMs: number,
|
|
466
|
+
): Promise<RunCommandSafelyResult> {
|
|
467
|
+
if (this.disposed) return { kind: "transient" };
|
|
468
|
+
const controller = new AbortController();
|
|
469
|
+
this.inflightControllers.add(controller);
|
|
470
|
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
471
|
+
try {
|
|
472
|
+
const result = await this.runner(command, args, {
|
|
473
|
+
cwd: this.cwd(),
|
|
474
|
+
signal: controller.signal,
|
|
475
|
+
});
|
|
476
|
+
return { kind: "ok", result };
|
|
477
|
+
} catch (error) {
|
|
478
|
+
return isCommandUnavailableError(error) ? { kind: "unavailable" } : { kind: "transient" };
|
|
479
|
+
} finally {
|
|
480
|
+
clearTimeout(timeoutId);
|
|
481
|
+
this.inflightControllers.delete(controller);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
dispose(): void {
|
|
486
|
+
if (this.disposed) return;
|
|
487
|
+
this.disposed = true;
|
|
488
|
+
if (this.intervalHandle !== undefined) {
|
|
489
|
+
this.clock.clearInterval(this.intervalHandle);
|
|
490
|
+
this.intervalHandle = undefined;
|
|
491
|
+
}
|
|
492
|
+
for (const controller of this.inflightControllers) controller.abort();
|
|
493
|
+
this.inflightControllers.clear();
|
|
494
|
+
}
|
|
461
495
|
}
|
|
462
496
|
|
|
463
497
|
export default function (pi: ExtensionAPI) {
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
498
|
+
let cache: GitFooterCache | undefined;
|
|
499
|
+
let updateStatus: (() => void) | undefined;
|
|
500
|
+
|
|
501
|
+
function disposeCache(): void {
|
|
502
|
+
cache?.dispose();
|
|
503
|
+
cache = undefined;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function ensureCache(ctx: ProjectGitContext): void {
|
|
507
|
+
if (!canRunProjectGit(ctx)) {
|
|
508
|
+
disposeCache();
|
|
509
|
+
updateStatus?.();
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
if (cache) return;
|
|
514
|
+
cache = new GitFooterCache({
|
|
515
|
+
cwd: () => ctx.cwd,
|
|
516
|
+
canRun: () => canRunProjectGit(ctx),
|
|
517
|
+
onChange: () => updateStatus?.(),
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
pi.on("session_start", (_event, ctx) => {
|
|
522
|
+
disposeCache();
|
|
523
|
+
updateStatus = () => {
|
|
524
|
+
const text = formatGitFooterStatus(
|
|
525
|
+
cache?.getStatusSnapshot(),
|
|
526
|
+
cache?.getPullRequestSnapshot(),
|
|
527
|
+
);
|
|
528
|
+
ctx.ui.setStatus(STATUS_KEY, text ? ctx.ui.theme.fg("dim", text) : undefined);
|
|
529
|
+
};
|
|
530
|
+
ensureCache(ctx);
|
|
531
|
+
updateStatus();
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
pi.on("turn_end", (_event, ctx) => {
|
|
535
|
+
ensureCache(ctx);
|
|
536
|
+
void cache?.refresh();
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
pi.on("session_shutdown", (_event, ctx) => {
|
|
540
|
+
disposeCache();
|
|
541
|
+
updateStatus = undefined;
|
|
542
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
543
|
+
});
|
|
497
544
|
}
|
|
498
545
|
|
|
499
546
|
export const __testing = {
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
547
|
+
GitFooterCache,
|
|
548
|
+
GIT_STATUS_ARGS,
|
|
549
|
+
GH_PR_VIEW_ARGS,
|
|
550
|
+
parseGitStatusPorcelainV2,
|
|
551
|
+
parsePullRequestJson,
|
|
552
|
+
formatGitStatusFooterSegment,
|
|
553
|
+
formatPullRequestFooterSegment,
|
|
554
|
+
formatGitFooterStatus,
|
|
508
555
|
};
|