@pi-archimedes/footer 2.10.4 → 2.11.1
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 +1 -1
- package/package.json +6 -6
- package/src/cost-source.test.ts +288 -0
- package/src/index.test.ts +380 -0
- package/src/index.ts +76 -37
- package/src/utils/git.test.ts +131 -2
- package/src/utils/git.ts +78 -43
- package/src/utils/jj.test.ts +68 -2
- package/src/utils/jj.ts +40 -13
- package/src/utils/stats.test.ts +35 -1
- package/src/utils/stats.ts +21 -1
- package/src/utils/vcs-change-events.test.ts +34 -0
- package/src/utils/vcs-change-events.ts +38 -0
- package/src/cost-accumulator.test.ts +0 -85
- package/src/cost-accumulator.ts +0 -35
package/src/utils/git.test.ts
CHANGED
|
@@ -17,11 +17,12 @@ describe("getGitStatus", () => {
|
|
|
17
17
|
let getGitStatus: () => GitStatus;
|
|
18
18
|
let isInsideLinkedWorktree: () => boolean;
|
|
19
19
|
|
|
20
|
-
async function loadModule(mockExecSync: ReturnType<typeof vi.fn
|
|
20
|
+
async function loadModule(mockExecSync: ReturnType<typeof vi.fn>, mockExecFile = vi.fn()) {
|
|
21
21
|
vi.resetModules();
|
|
22
22
|
|
|
23
23
|
vi.doMock("child_process", () => ({
|
|
24
24
|
execSync: mockExecSync,
|
|
25
|
+
execFile: mockExecFile,
|
|
25
26
|
}));
|
|
26
27
|
|
|
27
28
|
vi.doMock("fs", () => ({
|
|
@@ -31,6 +32,8 @@ describe("getGitStatus", () => {
|
|
|
31
32
|
const mod = await import("./git.js");
|
|
32
33
|
getGitStatus = mod.getGitStatus;
|
|
33
34
|
isInsideLinkedWorktree = mod.isInsideLinkedWorktree;
|
|
35
|
+
// Same module registry generation as git.js, so both see one listener set
|
|
36
|
+
return await import("./vcs-change-events.js");
|
|
34
37
|
}
|
|
35
38
|
|
|
36
39
|
afterEach(() => {
|
|
@@ -93,6 +96,107 @@ describe("getGitStatus", () => {
|
|
|
93
96
|
expect(result).toEqual({ staged: 0, unstaged: 0, untracked: 0, ahead: 3, behind: 2 });
|
|
94
97
|
});
|
|
95
98
|
|
|
99
|
+
it("refreshes stale status asynchronously and timestamps failed refreshes", async () => {
|
|
100
|
+
const mockExecSync = vi.fn(() => "1 AM file.txt\n");
|
|
101
|
+
const mockExecFile = vi.fn();
|
|
102
|
+
await loadModule(mockExecSync, mockExecFile);
|
|
103
|
+
const now = vi.spyOn(Date, "now").mockReturnValue(1_000);
|
|
104
|
+
|
|
105
|
+
expect(getGitStatus()).toEqual({ staged: 1, unstaged: 1, untracked: 0, ahead: 0, behind: 0 });
|
|
106
|
+
now.mockReturnValue(3_000);
|
|
107
|
+
expect(getGitStatus()).toEqual({ staged: 1, unstaged: 1, untracked: 0, ahead: 0, behind: 0 });
|
|
108
|
+
getGitStatus(); // renders while the refresh is pending
|
|
109
|
+
getGitStatus();
|
|
110
|
+
expect(mockExecFile).toHaveBeenCalledTimes(1);
|
|
111
|
+
expect(mockExecSync).toHaveBeenCalledTimes(1);
|
|
112
|
+
|
|
113
|
+
type Done = (error: Error | null, stdout: string) => void;
|
|
114
|
+
(mockExecFile.mock.calls[0]![3] as Done)(null, "? new.txt\n");
|
|
115
|
+
expect(getGitStatus()).toEqual({ staged: 0, unstaged: 0, untracked: 1, ahead: 0, behind: 0 });
|
|
116
|
+
|
|
117
|
+
now.mockReturnValue(5_001);
|
|
118
|
+
expect(getGitStatus()).toEqual({ staged: 0, unstaged: 0, untracked: 1, ahead: 0, behind: 0 });
|
|
119
|
+
(mockExecFile.mock.calls[1]![3] as Done)(new Error("git failed"), "");
|
|
120
|
+
expect(getGitStatus()).toEqual({ staged: 0, unstaged: 0, untracked: 1, ahead: 0, behind: 0 });
|
|
121
|
+
|
|
122
|
+
now.mockReturnValue(6_000);
|
|
123
|
+
getGitStatus();
|
|
124
|
+
expect(mockExecFile).toHaveBeenCalledTimes(2);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("publishes a VCS change when a background refresh moves the counts", async () => {
|
|
128
|
+
const mockExecSync = vi.fn(() => "1 AM file.txt\n");
|
|
129
|
+
const mockExecFile = vi.fn();
|
|
130
|
+
const events = await loadModule(mockExecSync, mockExecFile);
|
|
131
|
+
const now = vi.spyOn(Date, "now").mockReturnValue(1_000);
|
|
132
|
+
const listener = vi.fn();
|
|
133
|
+
const unsubscribe = events.onVcsChange(listener);
|
|
134
|
+
|
|
135
|
+
getGitStatus(); // first call is synchronous, nothing to announce yet
|
|
136
|
+
expect(listener).not.toHaveBeenCalled();
|
|
137
|
+
|
|
138
|
+
now.mockReturnValue(3_000);
|
|
139
|
+
getGitStatus(); // stale returned, refresh scheduled
|
|
140
|
+
expect(listener).not.toHaveBeenCalled();
|
|
141
|
+
|
|
142
|
+
type Done = (error: Error | null, stdout: string) => void;
|
|
143
|
+
(mockExecFile.mock.calls[0]![3] as Done)(null, "1 AM file.txt\n1 AM other.txt\n");
|
|
144
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
145
|
+
expect(getGitStatus().unstaged).toBe(2);
|
|
146
|
+
|
|
147
|
+
unsubscribe();
|
|
148
|
+
now.mockReturnValue(6_000);
|
|
149
|
+
getGitStatus();
|
|
150
|
+
(mockExecFile.mock.calls[1]![3] as Done)(null, "1 AM a\n1 AM b\n1 AM c\n");
|
|
151
|
+
expect(listener).toHaveBeenCalledTimes(1); // unsubscribed: silent
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("stays silent when a refresh returns identical counts or fails", async () => {
|
|
155
|
+
const mockExecSync = vi.fn(() => "1 AM file.txt\n");
|
|
156
|
+
const mockExecFile = vi.fn();
|
|
157
|
+
const events = await loadModule(mockExecSync, mockExecFile);
|
|
158
|
+
const now = vi.spyOn(Date, "now").mockReturnValue(1_000);
|
|
159
|
+
const listener = vi.fn();
|
|
160
|
+
events.onVcsChange(listener);
|
|
161
|
+
|
|
162
|
+
getGitStatus();
|
|
163
|
+
now.mockReturnValue(3_000);
|
|
164
|
+
getGitStatus();
|
|
165
|
+
type Done = (error: Error | null, stdout: string) => void;
|
|
166
|
+
(mockExecFile.mock.calls[0]![3] as Done)(null, "1 AM file.txt\n"); // same numbers
|
|
167
|
+
expect(listener).not.toHaveBeenCalled();
|
|
168
|
+
|
|
169
|
+
now.mockReturnValue(6_000);
|
|
170
|
+
getGitStatus();
|
|
171
|
+
(mockExecFile.mock.calls[1]![3] as Done)(new Error("git failed"), ""); // keeps stale
|
|
172
|
+
expect(listener).not.toHaveBeenCalled();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("publishes a VCS change when the worktree answer flips", async () => {
|
|
176
|
+
const cwd = process.cwd();
|
|
177
|
+
const mockExecSync = vi.fn(() => "worktree /nope/main\n\n" + `worktree ${cwd}\n`);
|
|
178
|
+
const mockExecFile = vi.fn();
|
|
179
|
+
const events = await loadModule(mockExecSync, mockExecFile);
|
|
180
|
+
const now = vi.spyOn(Date, "now").mockReturnValue(1_000);
|
|
181
|
+
const listener = vi.fn();
|
|
182
|
+
events.onVcsChange(listener);
|
|
183
|
+
|
|
184
|
+
expect(isInsideLinkedWorktree()).toBe(true);
|
|
185
|
+
expect(listener).not.toHaveBeenCalled();
|
|
186
|
+
|
|
187
|
+
now.mockReturnValue(3_000);
|
|
188
|
+
isInsideLinkedWorktree(); // schedules the refresh
|
|
189
|
+
type Done = (error: Error | null, stdout: string) => void;
|
|
190
|
+
(mockExecFile.mock.calls[0]![3] as Done)(null, `worktree ${cwd}\n`); // now the main worktree
|
|
191
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
192
|
+
expect(isInsideLinkedWorktree()).toBe(false);
|
|
193
|
+
|
|
194
|
+
now.mockReturnValue(6_000);
|
|
195
|
+
isInsideLinkedWorktree();
|
|
196
|
+
(mockExecFile.mock.calls[1]![3] as Done)(null, `worktree ${cwd}\n`); // same answer
|
|
197
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
198
|
+
});
|
|
199
|
+
|
|
96
200
|
it("malformed lines ignored gracefully", async () => {
|
|
97
201
|
const mockExecSync = vi.fn(() => "this is not valid\n\n \n1 AM valid.txt\n");
|
|
98
202
|
await loadModule(mockExecSync);
|
|
@@ -134,6 +238,26 @@ describe("getGitStatus", () => {
|
|
|
134
238
|
expect(isInsideLinkedWorktree()).toBe(true);
|
|
135
239
|
});
|
|
136
240
|
|
|
241
|
+
it("refreshes stale worktree status asynchronously", async () => {
|
|
242
|
+
const cwd = process.cwd();
|
|
243
|
+
const mockExecSync = vi.fn(() => "worktree /nope/main\n\n" + `worktree ${cwd}\n`);
|
|
244
|
+
const mockExecFile = vi.fn();
|
|
245
|
+
await loadModule(mockExecSync, mockExecFile);
|
|
246
|
+
const now = vi.spyOn(Date, "now").mockReturnValue(1_000);
|
|
247
|
+
|
|
248
|
+
expect(isInsideLinkedWorktree()).toBe(true);
|
|
249
|
+
now.mockReturnValue(3_000);
|
|
250
|
+
expect(isInsideLinkedWorktree()).toBe(true);
|
|
251
|
+
isInsideLinkedWorktree(); // renders while the refresh is pending
|
|
252
|
+
isInsideLinkedWorktree();
|
|
253
|
+
expect(mockExecFile).toHaveBeenCalledTimes(1);
|
|
254
|
+
expect(mockExecSync).toHaveBeenCalledTimes(1);
|
|
255
|
+
|
|
256
|
+
type Done = (error: Error | null, stdout: string) => void;
|
|
257
|
+
(mockExecFile.mock.calls[0]![3] as Done)(null, `worktree ${cwd}\n`);
|
|
258
|
+
expect(isInsideLinkedWorktree()).toBe(false);
|
|
259
|
+
});
|
|
260
|
+
|
|
137
261
|
it("property: getGitStatus never throws (catches execSync errors gracefully)", async () => {
|
|
138
262
|
const mockExecSync = vi.fn(() => { throw new Error("git not found"); });
|
|
139
263
|
await loadModule(mockExecSync);
|
|
@@ -142,7 +266,12 @@ describe("getGitStatus", () => {
|
|
|
142
266
|
expect(result).toEqual({ staged: 0, unstaged: 0, untracked: 0, ahead: 0, behind: 0 });
|
|
143
267
|
});
|
|
144
268
|
|
|
145
|
-
|
|
269
|
+
// Each of the 100 default runs re-imports the module (`vi.resetModules` +
|
|
270
|
+
// dynamic import in `loadModule`), so the cost is module-graph work, not
|
|
271
|
+
// parsing: 1217 ms idle, but up to 8247 ms with the repo's own type-check
|
|
272
|
+
// looped over it, and it crossed the 5 s default in 2 of 5 pre-fix loaded runs
|
|
273
|
+
// (5118 ms, 5178 ms). Same shield as the subagent width audit — see #131.
|
|
274
|
+
it("property: parseGitOutput handles arbitrary git porcelain output without throwing", { timeout: 30_000 }, async () => {
|
|
146
275
|
await fc.assert(
|
|
147
276
|
fc.asyncProperty(fc.string({ maxLength: 500 }), async (output) => {
|
|
148
277
|
const mockExecSync = vi.fn(() => output);
|
package/src/utils/git.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { execSync } from "child_process";
|
|
1
|
+
import { execFile, execSync } from "child_process";
|
|
2
2
|
import { realpathSync } from "fs";
|
|
3
|
+
import { notifyVcsChange } from "./vcs-change-events.js";
|
|
3
4
|
|
|
4
5
|
export interface GitStatus {
|
|
5
6
|
staged: number;
|
|
@@ -30,7 +31,8 @@ interface WorktreeCacheEntry {
|
|
|
30
31
|
|
|
31
32
|
let gitStatusCache: GitCacheEntry | undefined;
|
|
32
33
|
let worktreeCache: WorktreeCacheEntry | undefined;
|
|
33
|
-
let
|
|
34
|
+
let gitRefreshInFlight = false;
|
|
35
|
+
let worktreeRefreshInFlight = false;
|
|
34
36
|
|
|
35
37
|
interface FileStates {
|
|
36
38
|
indexField: string;
|
|
@@ -81,23 +83,35 @@ function parseGitOutput(output: string): GitStatus {
|
|
|
81
83
|
return status;
|
|
82
84
|
}
|
|
83
85
|
|
|
84
|
-
|
|
86
|
+
function gitStatusEquals(a: GitStatus, b: GitStatus): boolean {
|
|
87
|
+
return (
|
|
88
|
+
a.staged === b.staged &&
|
|
89
|
+
a.unstaged === b.unstaged &&
|
|
90
|
+
a.untracked === b.untracked &&
|
|
91
|
+
a.ahead === b.ahead &&
|
|
92
|
+
a.behind === b.behind
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Refresh git status without blocking the render loop. */
|
|
85
97
|
function scheduleGitRefresh(): void {
|
|
86
|
-
if (
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
gitStatusCache = { value:
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
98
|
+
if (gitRefreshInFlight || !gitStatusCache) return;
|
|
99
|
+
gitRefreshInFlight = true;
|
|
100
|
+
const staleValue = gitStatusCache.value;
|
|
101
|
+
execFile(
|
|
102
|
+
"git",
|
|
103
|
+
["status", "--porcelain=v2", "--branch", "-uall"],
|
|
104
|
+
{ cwd: process.cwd(), encoding: "utf8", timeout: 2_000 },
|
|
105
|
+
(error, stdout) => {
|
|
106
|
+
gitRefreshInFlight = false;
|
|
107
|
+
const next = error ? staleValue : parseGitOutput(stdout);
|
|
108
|
+
gitStatusCache = { value: next, timestamp: Date.now() };
|
|
109
|
+
// Ask for a frame only when a displayed count actually moved: the footer
|
|
110
|
+
// painted the stale value, and pi draws no frame unless one is requested.
|
|
111
|
+
// An unchanged reply must stay silent, or the TTL drives a render forever.
|
|
112
|
+
if (!gitStatusEquals(next, staleValue)) notifyVcsChange();
|
|
113
|
+
},
|
|
114
|
+
);
|
|
101
115
|
}
|
|
102
116
|
|
|
103
117
|
export function getGitStatus(): GitStatus {
|
|
@@ -112,7 +126,7 @@ export function getGitStatus(): GitStatus {
|
|
|
112
126
|
return gitStatusCache.value;
|
|
113
127
|
}
|
|
114
128
|
|
|
115
|
-
// First call — sync fetch
|
|
129
|
+
// First call — sync fetch so the first render is correct
|
|
116
130
|
const emptyStatus: GitStatus = { staged: 0, unstaged: 0, untracked: 0, ahead: 0, behind: 0 };
|
|
117
131
|
try {
|
|
118
132
|
const output = execSync(GIT_STATUS_CMD, {
|
|
@@ -126,10 +140,48 @@ export function getGitStatus(): GitStatus {
|
|
|
126
140
|
/* not a git repo or command failed */
|
|
127
141
|
gitStatusCache = { value: emptyStatus, timestamp: Date.now() };
|
|
128
142
|
}
|
|
129
|
-
scheduleGitRefresh();
|
|
130
143
|
return gitStatusCache.value;
|
|
131
144
|
}
|
|
132
145
|
|
|
146
|
+
function parseWorktreeOutput(output: string, realCwd: string): boolean {
|
|
147
|
+
const worktreeEntries = output.trim().split("\n\n").filter(Boolean);
|
|
148
|
+
if (worktreeEntries.length <= 1) return false;
|
|
149
|
+
|
|
150
|
+
// The first porcelain entry is always the main worktree: only linked ones
|
|
151
|
+
// can match.
|
|
152
|
+
for (let i = 1; i < worktreeEntries.length; i++) {
|
|
153
|
+
const pathLine = worktreeEntries[i]!.split("\n").find((line) => line.startsWith("worktree "));
|
|
154
|
+
const worktreePath = pathLine?.replace("worktree ", "");
|
|
155
|
+
if (worktreePath && (realCwd === worktreePath || realCwd.startsWith(worktreePath + "/"))) return true;
|
|
156
|
+
}
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function scheduleWorktreeRefresh(): void {
|
|
161
|
+
if (worktreeRefreshInFlight || !worktreeCache) return;
|
|
162
|
+
worktreeRefreshInFlight = true;
|
|
163
|
+
const staleValue = worktreeCache.value;
|
|
164
|
+
execFile(
|
|
165
|
+
"git",
|
|
166
|
+
["worktree", "list", "--porcelain"],
|
|
167
|
+
{ cwd: process.cwd(), encoding: "utf8", timeout: 2_000 },
|
|
168
|
+
(error, stdout) => {
|
|
169
|
+
worktreeRefreshInFlight = false;
|
|
170
|
+
let value = staleValue;
|
|
171
|
+
if (!error) {
|
|
172
|
+
try {
|
|
173
|
+
value = parseWorktreeOutput(stdout, realpathSync(process.cwd()));
|
|
174
|
+
} catch {
|
|
175
|
+
/* keep stale value */
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
worktreeCache = { value, timestamp: Date.now() };
|
|
179
|
+
// Same rule as git status: repaint only when the answer changed.
|
|
180
|
+
if (value !== staleValue) notifyVcsChange();
|
|
181
|
+
},
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
133
185
|
/**
|
|
134
186
|
* Returns true when cwd is inside a linked worktree (not the main clone).
|
|
135
187
|
* Returns false when cwd is the main clone or not a git repo.
|
|
@@ -140,6 +192,11 @@ export function isInsideLinkedWorktree(): boolean {
|
|
|
140
192
|
return worktreeCache.value;
|
|
141
193
|
}
|
|
142
194
|
|
|
195
|
+
if (worktreeCache) {
|
|
196
|
+
scheduleWorktreeRefresh();
|
|
197
|
+
return worktreeCache.value;
|
|
198
|
+
}
|
|
199
|
+
|
|
143
200
|
try {
|
|
144
201
|
const worktreeOutput = execSync("git worktree list --porcelain", {
|
|
145
202
|
cwd: process.cwd(),
|
|
@@ -147,29 +204,7 @@ export function isInsideLinkedWorktree(): boolean {
|
|
|
147
204
|
stdio: ["pipe", "pipe", "pipe"],
|
|
148
205
|
timeout: 2_000,
|
|
149
206
|
});
|
|
150
|
-
|
|
151
|
-
const worktreeEntries = worktreeOutput.trim().split("\n\n").filter(Boolean);
|
|
152
|
-
if (worktreeEntries.length <= 1) {
|
|
153
|
-
worktreeCache = { value: false, timestamp: Date.now() };
|
|
154
|
-
return false;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
const currentDirectoryPath = realpathSync(process.cwd());
|
|
158
|
-
let result = false;
|
|
159
|
-
// The first porcelain entry is always the main worktree: being in the
|
|
160
|
-
// main clone isn't "being in a worktree", so only linked ones (index >= 1)
|
|
161
|
-
// can match.
|
|
162
|
-
for (let i = 1; i < worktreeEntries.length; i++) {
|
|
163
|
-
const entry = worktreeEntries[i]!;
|
|
164
|
-
const entryLines = entry.split("\n");
|
|
165
|
-
const pathLine = entryLines.find((l) => l.startsWith("worktree "));
|
|
166
|
-
const worktreePath = pathLine?.replace("worktree ", "");
|
|
167
|
-
|
|
168
|
-
if (worktreePath && (currentDirectoryPath === worktreePath || currentDirectoryPath.startsWith(worktreePath + "/"))) {
|
|
169
|
-
result = true;
|
|
170
|
-
break;
|
|
171
|
-
}
|
|
172
|
-
}
|
|
207
|
+
const result = parseWorktreeOutput(worktreeOutput, realpathSync(process.cwd()));
|
|
173
208
|
worktreeCache = { value: result, timestamp: Date.now() };
|
|
174
209
|
return result;
|
|
175
210
|
} catch {
|
package/src/utils/jj.test.ts
CHANGED
|
@@ -12,8 +12,9 @@ async function load({ paths = [".jj", ".git"], output = "main\n", throws = false
|
|
|
12
12
|
const execFile = vi.fn();
|
|
13
13
|
vi.doMock("child_process", () => ({ execFileSync, execFile }));
|
|
14
14
|
vi.doMock("fs", () => ({ existsSync: (p: string) => existing.has(p) }));
|
|
15
|
-
const { getJjBookmark } = await import("./jj.js");
|
|
16
|
-
|
|
15
|
+
const { getJjBookmark, getJjChangedFiles } = await import("./jj.js");
|
|
16
|
+
const events = await import("./vcs-change-events.js");
|
|
17
|
+
return { getJjBookmark, getJjChangedFiles, execFileSync, execFile, existing, events };
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
describe("getJjBookmark", () => {
|
|
@@ -38,6 +39,18 @@ describe("getJjBookmark", () => {
|
|
|
38
39
|
expect(getJjBookmark()).toBe(expected);
|
|
39
40
|
});
|
|
40
41
|
|
|
42
|
+
it("parses the jj changed-file count", async () => {
|
|
43
|
+
const { getJjBookmark, getJjChangedFiles } = await load({ output: "@mooqvmmx\nmain\n\t3\n" });
|
|
44
|
+
expect(getJjBookmark()).toBe("main");
|
|
45
|
+
expect(getJjChangedFiles()).toBe(3);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("keeps bookmarks that start with !", async () => {
|
|
49
|
+
const { getJjBookmark, getJjChangedFiles } = await load({ output: "!feature\n\t3\n" });
|
|
50
|
+
expect(getJjBookmark()).toBe("!feature");
|
|
51
|
+
expect(getJjChangedFiles()).toBe(3);
|
|
52
|
+
});
|
|
53
|
+
|
|
41
54
|
it("returns null when jj fails", async () => {
|
|
42
55
|
const { getJjBookmark } = await load({ throws: true });
|
|
43
56
|
expect(getJjBookmark()).toBeNull();
|
|
@@ -67,4 +80,57 @@ describe("getJjBookmark", () => {
|
|
|
67
80
|
expect(getJjBookmark()).toBe("feature-x");
|
|
68
81
|
now.mockRestore();
|
|
69
82
|
});
|
|
83
|
+
|
|
84
|
+
it("publishes a VCS change when a refresh moves the bookmark or the file count", async () => {
|
|
85
|
+
const { getJjBookmark, execFile, events } = await load({ output: "main\n\t1\n" });
|
|
86
|
+
const now = vi.spyOn(Date, "now").mockReturnValue(1_000);
|
|
87
|
+
const listener = vi.fn();
|
|
88
|
+
events.onVcsChange(listener);
|
|
89
|
+
|
|
90
|
+
expect(getJjBookmark()).toBe("main"); // synchronous first call: nothing to announce
|
|
91
|
+
expect(listener).not.toHaveBeenCalled();
|
|
92
|
+
|
|
93
|
+
type Done = (e: Error | null, out: string) => void;
|
|
94
|
+
now.mockReturnValue(10_000);
|
|
95
|
+
getJjBookmark(); // stale while refreshing
|
|
96
|
+
(execFile.mock.calls[0]![3] as Done)(null, "main\n\t4\n"); // same bookmark, more files
|
|
97
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
98
|
+
|
|
99
|
+
now.mockReturnValue(20_000);
|
|
100
|
+
getJjBookmark();
|
|
101
|
+
(execFile.mock.calls[1]![3] as Done)(null, "main\n\t4\n"); // identical → silent
|
|
102
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
103
|
+
now.mockRestore();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("stays silent on a failed refresh and on a reply that raced a cwd change", async () => {
|
|
107
|
+
// The other repo also looks like a jj repo to the mocked fs, so a call
|
|
108
|
+
// after chdir replaces the cache entry with one keyed to the new cwd.
|
|
109
|
+
const other = "/somewhere/else";
|
|
110
|
+
const { getJjBookmark, getJjChangedFiles, execFile, events } = await load({
|
|
111
|
+
output: "main\n\t1\n",
|
|
112
|
+
paths: [".jj", ".git", `${other}/.jj`],
|
|
113
|
+
});
|
|
114
|
+
const now = vi.spyOn(Date, "now").mockReturnValue(1_000);
|
|
115
|
+
const listener = vi.fn();
|
|
116
|
+
events.onVcsChange(listener);
|
|
117
|
+
getJjBookmark();
|
|
118
|
+
|
|
119
|
+
type Done = (e: Error | null, out: string) => void;
|
|
120
|
+
now.mockReturnValue(10_000);
|
|
121
|
+
getJjBookmark(); // schedules the refresh for the original cwd
|
|
122
|
+
(execFile.mock.calls[0]![3] as Done)(new Error("lock timeout"), "");
|
|
123
|
+
expect(listener).not.toHaveBeenCalled();
|
|
124
|
+
|
|
125
|
+
now.mockReturnValue(20_000);
|
|
126
|
+
getJjBookmark(); // still the original cwd, request in flight
|
|
127
|
+
const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(other);
|
|
128
|
+
expect(getJjBookmark()).toBe("main"); // moved: cache rewritten for the new cwd
|
|
129
|
+
expect(getJjChangedFiles()).toBe(1);
|
|
130
|
+
(execFile.mock.calls[1]![3] as Done)(null, "other-branch\n\t9\n"); // late reply for the old cwd
|
|
131
|
+
expect(getJjBookmark()).toBe("main"); // the dropped reply must not resurface
|
|
132
|
+
cwdSpy.mockRestore();
|
|
133
|
+
expect(listener).not.toHaveBeenCalled();
|
|
134
|
+
now.mockRestore();
|
|
135
|
+
});
|
|
70
136
|
});
|
package/src/utils/jj.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { execFile, execFileSync } from "child_process";
|
|
2
2
|
import { existsSync } from "fs";
|
|
3
3
|
import { dirname, join } from "path";
|
|
4
|
+
import { notifyVcsChange } from "./vcs-change-events.js";
|
|
4
5
|
|
|
5
6
|
// Same TTL as git status: bookmarks (and repo markers) don't change on every render
|
|
6
7
|
const JJ_CACHE_TTL_MS = 2_000;
|
|
@@ -13,11 +14,12 @@ const JJ_ARGS = [
|
|
|
13
14
|
"--no-graph",
|
|
14
15
|
"--ignore-working-copy",
|
|
15
16
|
"--color", "never",
|
|
16
|
-
|
|
17
|
+
// Count line is tab-tagged (git refs can't contain tabs); it comes from the last snapshot, so it lags until jj snapshots.
|
|
18
|
+
"-T", 'if(local_bookmarks, local_bookmarks.map(|b| b.name()).join(" "), "@" ++ change_id.shortest(8)) ++ "\\n" ++ if(current_working_copy, "\\t" ++ self.diff().files().len() ++ "\\n")',
|
|
17
19
|
];
|
|
18
20
|
|
|
19
21
|
let jjRepoCache: { cwd: string; isJj: boolean; timestamp: number } | undefined;
|
|
20
|
-
let bookmarkCache: { cwd: string; value: string | null; timestamp: number } | undefined;
|
|
22
|
+
let bookmarkCache: { cwd: string; value: string | null; changedFiles: number; timestamp: number } | undefined;
|
|
21
23
|
let refreshInFlight = false;
|
|
22
24
|
|
|
23
25
|
function isJjRepo(cwd: string): boolean {
|
|
@@ -43,13 +45,19 @@ function isJjRepo(cwd: string): boolean {
|
|
|
43
45
|
* Several bookmarks on one commit collapse to `first +N` — footer chunks are
|
|
44
46
|
* atomic, so an unbounded list could push other sections onto their own lines.
|
|
45
47
|
*/
|
|
46
|
-
function parseJjOutput(output: string): string | null {
|
|
47
|
-
const
|
|
48
|
-
const
|
|
49
|
-
|
|
48
|
+
function parseJjOutput(output: string): { value: string | null; changedFiles: number } {
|
|
49
|
+
const outputLines = output.split("\n").filter(Boolean);
|
|
50
|
+
const changedFilesLine = outputLines.find((line) => line.startsWith("\t"));
|
|
51
|
+
const changedFiles = Number(changedFilesLine?.slice(1)) || 0;
|
|
52
|
+
const revisionLines = outputLines.filter((line) => !line.startsWith("\t"));
|
|
53
|
+
const bookmarks = revisionLines.find((line) => !line.startsWith("@"));
|
|
54
|
+
if (!bookmarks) return { value: revisionLines[0]?.slice(1) || null, changedFiles };
|
|
50
55
|
// Space-joined by the template: git ref names can't contain spaces
|
|
51
|
-
const [
|
|
52
|
-
return
|
|
56
|
+
const [firstBookmark, ...remainingBookmarks] = bookmarks.split(" ");
|
|
57
|
+
return {
|
|
58
|
+
value: remainingBookmarks.length ? `${firstBookmark} +${remainingBookmarks.length}` : firstBookmark!,
|
|
59
|
+
changedFiles,
|
|
60
|
+
};
|
|
53
61
|
}
|
|
54
62
|
|
|
55
63
|
function refreshInBackground(cwd: string): void {
|
|
@@ -57,12 +65,28 @@ function refreshInBackground(cwd: string): void {
|
|
|
57
65
|
refreshInFlight = true;
|
|
58
66
|
execFile("jj", JJ_ARGS, { cwd, encoding: "utf8", timeout: 2_000 }, (error, stdout) => {
|
|
59
67
|
refreshInFlight = false;
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
68
|
+
const previous = bookmarkCache;
|
|
69
|
+
if (!previous || previous.cwd !== cwd) return; // cwd moved on; don't clobber the new entry
|
|
70
|
+
// On failure keep stale values (transient lock/timeout shouldn't flash "detached")
|
|
71
|
+
if (error) {
|
|
72
|
+
bookmarkCache = { ...previous, timestamp: Date.now() };
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const parsedOutput = parseJjOutput(stdout);
|
|
76
|
+
bookmarkCache = { cwd, ...parsedOutput, timestamp: Date.now() };
|
|
77
|
+
// The footer painted the stale bookmark/count and pi draws no frame unless
|
|
78
|
+
// one is requested, so a reply that moved either has to ask for a repaint.
|
|
79
|
+
if (previous.value !== parsedOutput.value || previous.changedFiles !== parsedOutput.changedFiles) {
|
|
80
|
+
notifyVcsChange();
|
|
81
|
+
}
|
|
63
82
|
});
|
|
64
83
|
}
|
|
65
84
|
|
|
85
|
+
/** @'s changed-file count; only meaningful when getJjBookmark() returned non-null this render. */
|
|
86
|
+
export function getJjChangedFiles(): number {
|
|
87
|
+
return bookmarkCache?.cwd === process.cwd() ? bookmarkCache.changedFiles : 0;
|
|
88
|
+
}
|
|
89
|
+
|
|
66
90
|
/**
|
|
67
91
|
* Current jj bookmark for the footer branch section, or null when cwd isn't in
|
|
68
92
|
* a jj repo or jj can't be run (caller falls back to pi's git branch).
|
|
@@ -77,6 +101,7 @@ export function getJjBookmark(): string | null {
|
|
|
77
101
|
}
|
|
78
102
|
|
|
79
103
|
let value: string | null = null;
|
|
104
|
+
let changedFiles = 0;
|
|
80
105
|
try {
|
|
81
106
|
const output = execFileSync("jj", JJ_ARGS, {
|
|
82
107
|
cwd,
|
|
@@ -84,10 +109,12 @@ export function getJjBookmark(): string | null {
|
|
|
84
109
|
stdio: ["ignore", "pipe", "ignore"],
|
|
85
110
|
timeout: 2_000,
|
|
86
111
|
});
|
|
87
|
-
|
|
112
|
+
const parsedOutput = parseJjOutput(output);
|
|
113
|
+
value = parsedOutput.value;
|
|
114
|
+
changedFiles = parsedOutput.changedFiles;
|
|
88
115
|
} catch {
|
|
89
116
|
/* jj missing or failed */
|
|
90
117
|
}
|
|
91
|
-
bookmarkCache = { cwd, value, timestamp: Date.now() };
|
|
118
|
+
bookmarkCache = { cwd, value, changedFiles, timestamp: Date.now() };
|
|
92
119
|
return value;
|
|
93
120
|
}
|
package/src/utils/stats.test.ts
CHANGED
|
@@ -85,7 +85,7 @@ function makeCompactionEntry(id: string, usage?: MessageUsage): SessionEntry {
|
|
|
85
85
|
|
|
86
86
|
function makeCtx(entries: SessionEntry[], contextUsage?: { contextWindow?: number; percent?: number }, modelContextWindow?: number): any {
|
|
87
87
|
return {
|
|
88
|
-
sessionManager: { getEntries: () => entries },
|
|
88
|
+
sessionManager: { getEntries: () => entries, getSessionId: () => "session-1", getLeafId: () => entries.at(-1)?.id ?? null },
|
|
89
89
|
getContextUsage: () => contextUsage,
|
|
90
90
|
model: modelContextWindow ? { contextWindow: modelContextWindow } : undefined,
|
|
91
91
|
};
|
|
@@ -366,11 +366,13 @@ describe("getTokenUsageStats", () => {
|
|
|
366
366
|
|
|
367
367
|
describe("getContextWindowInfo", () => {
|
|
368
368
|
let getContextWindowInfo: typeof import("./stats.js").getContextWindowInfo;
|
|
369
|
+
let resetStatsState: typeof import("./stats.js").resetStatsState;
|
|
369
370
|
|
|
370
371
|
async function loadModule() {
|
|
371
372
|
vi.resetModules();
|
|
372
373
|
const mod = await import("./stats.js");
|
|
373
374
|
getContextWindowInfo = mod.getContextWindowInfo;
|
|
375
|
+
resetStatsState = mod.resetStatsState;
|
|
374
376
|
}
|
|
375
377
|
|
|
376
378
|
it("computes percentage correctly", async () => {
|
|
@@ -405,4 +407,36 @@ describe("getContextWindowInfo", () => {
|
|
|
405
407
|
expect(result.percentValue).toBe(10);
|
|
406
408
|
expect(result.windowSize).toBe(10000);
|
|
407
409
|
});
|
|
410
|
+
|
|
411
|
+
it("reuses context usage until the session moves", async () => {
|
|
412
|
+
await loadModule();
|
|
413
|
+
const entries = [makeAssistantEntry({ input: 500, output: 500, cacheRead: 0, cacheWrite: 0, cost: { total: 0 } })];
|
|
414
|
+
const ctx = makeCtx(entries, { contextWindow: 10000, percent: 10 });
|
|
415
|
+
const getContextUsageSpy = vi.spyOn(ctx, "getContextUsage");
|
|
416
|
+
|
|
417
|
+
getContextWindowInfo(ctx);
|
|
418
|
+
getContextWindowInfo(ctx);
|
|
419
|
+
expect(getContextUsageSpy).toHaveBeenCalledTimes(1);
|
|
420
|
+
|
|
421
|
+
entries.push(makeAssistantEntry({ input: 100, output: 100, cacheRead: 0, cacheWrite: 0, cost: { total: 0 } }));
|
|
422
|
+
getContextWindowInfo(ctx);
|
|
423
|
+
expect(getContextUsageSpy).toHaveBeenCalledTimes(2);
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
// Each key alone must invalidate: same entry count, new usage -> new percent
|
|
427
|
+
it.each([
|
|
428
|
+
["session", (ctx: any) => { ctx.sessionManager.getSessionId = () => "session-2"; }],
|
|
429
|
+
["leaf (same entry count)", (ctx: any) => { ctx.sessionManager.getLeafId = () => "other-leaf"; }],
|
|
430
|
+
["model", (ctx: any) => { ctx.model = { contextWindow: 10000 }; }],
|
|
431
|
+
["reset", () => resetStatsState()],
|
|
432
|
+
])("recomputes context usage after a %s change", async (_change, applyChange) => {
|
|
433
|
+
await loadModule();
|
|
434
|
+
const ctx = makeCtx([makeAssistantEntry({ input: 500, output: 500, cacheRead: 0, cacheWrite: 0, cost: { total: 0 } })], { contextWindow: 10000, percent: 10 });
|
|
435
|
+
expect(getContextWindowInfo(ctx).percentValue).toBe(10);
|
|
436
|
+
|
|
437
|
+
ctx.getContextUsage = () => ({ contextWindow: 10000, percent: 25 });
|
|
438
|
+
expect(getContextWindowInfo(ctx).percentValue).toBe(10); // cached
|
|
439
|
+
applyChange(ctx);
|
|
440
|
+
expect(getContextWindowInfo(ctx).percentValue).toBe(25);
|
|
441
|
+
});
|
|
408
442
|
});
|
package/src/utils/stats.ts
CHANGED
|
@@ -195,6 +195,25 @@ export function getTokenUsageStats(ctx: ExtensionContext): TokenUsageStats {
|
|
|
195
195
|
return runningTotal;
|
|
196
196
|
}
|
|
197
197
|
|
|
198
|
+
// getContextUsage() rebuilds the session projection; only recompute when the session moves
|
|
199
|
+
let contextUsageCache:
|
|
200
|
+
| { sessionId: string; leafId: string | null; entryCount: number; model: unknown; value: ReturnType<ExtensionContext["getContextUsage"]> }
|
|
201
|
+
| undefined;
|
|
202
|
+
|
|
203
|
+
function getCachedContextUsage(ctx: ExtensionContext): ReturnType<ExtensionContext["getContextUsage"]> {
|
|
204
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
205
|
+
const leafId = ctx.sessionManager.getLeafId();
|
|
206
|
+
const entryCount = ctx.sessionManager.getEntries().length;
|
|
207
|
+
const model = ctx.model;
|
|
208
|
+
const cached = contextUsageCache;
|
|
209
|
+
if (cached && cached.sessionId === sessionId && cached.leafId === leafId && cached.entryCount === entryCount && cached.model === model) {
|
|
210
|
+
return cached.value;
|
|
211
|
+
}
|
|
212
|
+
const value = ctx.getContextUsage();
|
|
213
|
+
contextUsageCache = { sessionId, leafId, entryCount, model, value };
|
|
214
|
+
return value;
|
|
215
|
+
}
|
|
216
|
+
|
|
198
217
|
/** Clear the stats cache — call when a new message arrives. */
|
|
199
218
|
export function invalidateStatsCache(): void {
|
|
200
219
|
statsCache = undefined;
|
|
@@ -208,6 +227,7 @@ export function resetStatsState(): void {
|
|
|
208
227
|
lastAnchorEntryId = undefined;
|
|
209
228
|
lastTailUsage = undefined;
|
|
210
229
|
statsCache = undefined;
|
|
230
|
+
contextUsageCache = undefined;
|
|
211
231
|
}
|
|
212
232
|
|
|
213
233
|
export interface ContextWindowInfo {
|
|
@@ -217,7 +237,7 @@ export interface ContextWindowInfo {
|
|
|
217
237
|
}
|
|
218
238
|
|
|
219
239
|
export function getContextWindowInfo(ctx: ExtensionContext): ContextWindowInfo {
|
|
220
|
-
const contextUsage = ctx
|
|
240
|
+
const contextUsage = getCachedContextUsage(ctx);
|
|
221
241
|
const modelContextWindow = contextUsage?.contextWindow ?? ctx.model?.contextWindow ?? 0;
|
|
222
242
|
const tokenStats = getTokenUsageStats(ctx);
|
|
223
243
|
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { onVcsChange, notifyVcsChange } from "./vcs-change-events.js";
|
|
3
|
+
|
|
4
|
+
describe("vcs-change-events", () => {
|
|
5
|
+
it("notifies every listener", () => {
|
|
6
|
+
const a = vi.fn();
|
|
7
|
+
const b = vi.fn();
|
|
8
|
+
onVcsChange(a);
|
|
9
|
+
onVcsChange(b);
|
|
10
|
+
notifyVcsChange();
|
|
11
|
+
expect(a).toHaveBeenCalledTimes(1);
|
|
12
|
+
expect(b).toHaveBeenCalledTimes(1);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("stops notifying after unsubscribe", () => {
|
|
16
|
+
const listener = vi.fn();
|
|
17
|
+
const unsubscribe = onVcsChange(listener);
|
|
18
|
+
unsubscribe();
|
|
19
|
+
unsubscribe(); // idempotent
|
|
20
|
+
notifyVcsChange();
|
|
21
|
+
expect(listener).not.toHaveBeenCalled();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("a throwing listener does not break the others or the publisher", () => {
|
|
25
|
+
const bad = vi.fn(() => {
|
|
26
|
+
throw new Error("dead footer");
|
|
27
|
+
});
|
|
28
|
+
const good = vi.fn();
|
|
29
|
+
onVcsChange(bad);
|
|
30
|
+
onVcsChange(good);
|
|
31
|
+
expect(() => notifyVcsChange()).not.toThrow();
|
|
32
|
+
expect(good).toHaveBeenCalledTimes(1);
|
|
33
|
+
});
|
|
34
|
+
});
|