@promptctl/cc-candybar 1.18.0 → 1.18.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/dist/index.mjs +61 -61
- package/package.json +6 -6
- package/src/daemon/cache/watchers.ts +5 -1
- package/src/segments/git.ts +126 -172
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@promptctl/cc-candybar",
|
|
3
|
-
"version": "1.18.
|
|
3
|
+
"version": "1.18.1",
|
|
4
4
|
"description": "Statusline renderer for Claude Code — a JSON5-configurable DSL with daemon-cached data sources, byte-clean palette-aware composition, and OSC8 click verbs.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.mjs",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"preloadtest": "pnpm build",
|
|
28
28
|
"loadtest": "node --import tsx scripts/daemon-load-harness.ts",
|
|
29
29
|
"preloadtest:gate": "pnpm build",
|
|
30
|
-
"loadtest:gate": "node --import tsx scripts/daemon-load-harness.ts --sessions 25 --interval 300 --duration 20 --churn --transcript-lines 5000",
|
|
30
|
+
"loadtest:gate": "node --import tsx scripts/daemon-load-harness.ts --sessions 25 --interval 300 --duration 20 --churn --git-churn --transcript-lines 5000",
|
|
31
31
|
"check:protocol": "node scripts/check-protocol.mjs",
|
|
32
32
|
"gen:schema": "tsx scripts/gen-schema.ts",
|
|
33
33
|
"check:schema": "tsx scripts/check-schema.ts",
|
|
@@ -95,10 +95,10 @@
|
|
|
95
95
|
"mobx": "^6.15.0"
|
|
96
96
|
},
|
|
97
97
|
"optionalDependencies": {
|
|
98
|
-
"@promptctl/cc-candybar-darwin-arm64": "1.18.
|
|
99
|
-
"@promptctl/cc-candybar-darwin-x64": "1.18.
|
|
100
|
-
"@promptctl/cc-candybar-linux-x64": "1.18.
|
|
101
|
-
"@promptctl/cc-candybar-linux-arm64": "1.18.
|
|
98
|
+
"@promptctl/cc-candybar-darwin-arm64": "1.18.1",
|
|
99
|
+
"@promptctl/cc-candybar-darwin-x64": "1.18.1",
|
|
100
|
+
"@promptctl/cc-candybar-linux-x64": "1.18.1",
|
|
101
|
+
"@promptctl/cc-candybar-linux-arm64": "1.18.1"
|
|
102
102
|
},
|
|
103
103
|
"pnpm": {
|
|
104
104
|
"supportedArchitectures": {
|
|
@@ -18,7 +18,11 @@ const defaultLogger: WatcherLogger = (_level, message) => debug(message);
|
|
|
18
18
|
// (git cache, config cache, ...). Scattered watchers across modules would leak
|
|
19
19
|
// FDs and miss cleanup at shutdown.
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
// [LAW:one-source-of-truth] Exported: the invalidation-debounce floor is the
|
|
22
|
+
// single source consumers derive from (e.g. the load harness's git-churn
|
|
23
|
+
// interval must sit above it). Restating "50" anywhere else would let the two
|
|
24
|
+
// drift silently.
|
|
25
|
+
export const DEBOUNCE_MS = 50;
|
|
22
26
|
const DEFAULT_MAX_WATCHERS = 128;
|
|
23
27
|
|
|
24
28
|
// Absolute filesystem paths the consumer wants invalidation for.
|
package/src/segments/git.ts
CHANGED
|
@@ -188,6 +188,110 @@ function isRecord(v: unknown): v is Record<string, unknown> {
|
|
|
188
188
|
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
+
// [LAW:decomposition] The core git snapshot as a SINGLE `git status
|
|
192
|
+
// --porcelain=v2 --branch` yields it. That one invocation reports branch,
|
|
193
|
+
// short SHA, upstream, ahead/behind, and the full worktree status — so the
|
|
194
|
+
// prior fan-out (status -b + two rev-list + a branch-fallback + rev-parse HEAD
|
|
195
|
+
// + rev-parse @{u}, up to six spawns) collapses to one. Fewer spawns per cache
|
|
196
|
+
// miss is the whole point of brandon-daemon-perf-bb9.1; folding these also
|
|
197
|
+
// makes ahead/behind SHARE FATE with status (same subprocess) instead of being
|
|
198
|
+
// an independently-failable partial state ([LAW:types-are-the-program]).
|
|
199
|
+
interface CoreStatus {
|
|
200
|
+
branch: string;
|
|
201
|
+
status: "clean" | "dirty" | "conflicts";
|
|
202
|
+
workingTree: WorkingTree;
|
|
203
|
+
// Each an Outcome so "no upstream / unborn HEAD" (absent) stays distinct from
|
|
204
|
+
// a value — the same three-state contract every on-demand field carries.
|
|
205
|
+
aheadBehind: Outcome<AheadBehind>;
|
|
206
|
+
sha: Outcome<string>;
|
|
207
|
+
upstream: Outcome<string>;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// [LAW:effects-at-boundaries] Pure text→data: the subprocess (the effect) lives
|
|
211
|
+
// in getCoreAsync; this parses its stdout. Exported so the accept/reject shape
|
|
212
|
+
// table is unit-testable without spawning git.
|
|
213
|
+
//
|
|
214
|
+
// Porcelain v2 header lines (`# branch.<field> <value>`) carry branch/oid/
|
|
215
|
+
// upstream/ab; entry lines classify the worktree:
|
|
216
|
+
// `1 XY …` ordinary change → XY[0]=index, XY[1]=worktree ('.' = unmodified)
|
|
217
|
+
// `2 XY …` rename/copy → same XY columns
|
|
218
|
+
// `u …` unmerged → a conflict
|
|
219
|
+
// `? path` untracked
|
|
220
|
+
// `! path` ignored (never requested here; skipped)
|
|
221
|
+
// The `(initial)` oid (unborn HEAD) and `(detached)` head are git's sentinels
|
|
222
|
+
// for "no commit yet" and "detached" — mapped to absent-sha and the "detached"
|
|
223
|
+
// branch label respectively, matching the prior fallback-chain behavior.
|
|
224
|
+
export function parseStatusV2(stdout: string): CoreStatus {
|
|
225
|
+
let branch = "detached";
|
|
226
|
+
let sha: Outcome<string> = ABSENT;
|
|
227
|
+
let upstream: Outcome<string> = ABSENT;
|
|
228
|
+
let aheadBehind: Outcome<AheadBehind> = ABSENT;
|
|
229
|
+
let staged = 0;
|
|
230
|
+
let unstaged = 0;
|
|
231
|
+
let untracked = 0;
|
|
232
|
+
let conflicts = 0;
|
|
233
|
+
|
|
234
|
+
for (const line of stdout.split("\n")) {
|
|
235
|
+
if (!line) continue;
|
|
236
|
+
|
|
237
|
+
if (line.startsWith("# ")) {
|
|
238
|
+
const rest = line.slice(2);
|
|
239
|
+
if (rest.startsWith("branch.oid ")) {
|
|
240
|
+
const v = rest.slice("branch.oid ".length).trim();
|
|
241
|
+
// Fixed 7-char truncation is the display contract. `git rev-parse
|
|
242
|
+
// --short` auto-lengthens on collision, but re-spawning it here to
|
|
243
|
+
// recover that would undo this segment's whole point — one porcelain=v2
|
|
244
|
+
// read instead of a fan-out. The sha is display-only (never a lookup
|
|
245
|
+
// key), so a 7-char ambiguity in a >1M-object repo is cosmetic.
|
|
246
|
+
sha = v === "(initial)" ? ABSENT : ok(v.slice(0, 7));
|
|
247
|
+
} else if (rest.startsWith("branch.head ")) {
|
|
248
|
+
const v = rest.slice("branch.head ".length).trim();
|
|
249
|
+
branch = v === "(detached)" ? "detached" : v;
|
|
250
|
+
} else if (rest.startsWith("branch.upstream ")) {
|
|
251
|
+
upstream = ok(rest.slice("branch.upstream ".length).trim());
|
|
252
|
+
} else if (rest.startsWith("branch.ab ")) {
|
|
253
|
+
// Format is exactly "+<ahead> -<behind>"; a shape mismatch leaves
|
|
254
|
+
// aheadBehind absent rather than fabricating a count.
|
|
255
|
+
const m = rest
|
|
256
|
+
.slice("branch.ab ".length)
|
|
257
|
+
.trim()
|
|
258
|
+
.match(/^\+(\d+)\s+-(\d+)$/);
|
|
259
|
+
if (m) {
|
|
260
|
+
aheadBehind = ok({
|
|
261
|
+
ahead: parseInt(m[1]!, 10),
|
|
262
|
+
behind: parseInt(m[2]!, 10),
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const kind = line[0];
|
|
270
|
+
if (kind === "1" || kind === "2") {
|
|
271
|
+
const xy = line.slice(2, 4);
|
|
272
|
+
if (xy[0] !== ".") staged++;
|
|
273
|
+
if (xy[1] !== ".") unstaged++;
|
|
274
|
+
} else if (kind === "u") {
|
|
275
|
+
conflicts++;
|
|
276
|
+
} else if (kind === "?") {
|
|
277
|
+
untracked++;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
let status: "clean" | "dirty" | "conflicts" = "clean";
|
|
282
|
+
if (conflicts > 0) status = "conflicts";
|
|
283
|
+
else if (staged || unstaged || untracked) status = "dirty";
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
branch,
|
|
287
|
+
status,
|
|
288
|
+
aheadBehind,
|
|
289
|
+
sha,
|
|
290
|
+
upstream,
|
|
291
|
+
workingTree: { staged, unstaged, untracked, conflicts },
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
191
295
|
// `gh pr view --json number,state,url` → one JSON object. Only an OPEN PR is a
|
|
192
296
|
// value; a MERGED/CLOSED PR for the branch is the domain's `absent`.
|
|
193
297
|
export function parseGithubPr(stdout: string): Outcome<PullRequest> {
|
|
@@ -346,28 +450,27 @@ export class GitService {
|
|
|
346
450
|
gitDir = foundGitRoot.value;
|
|
347
451
|
}
|
|
348
452
|
|
|
349
|
-
// branch/status are the core
|
|
350
|
-
//
|
|
351
|
-
//
|
|
352
|
-
|
|
453
|
+
// branch/status/ahead-behind/sha/upstream are the core, and one
|
|
454
|
+
// `git status --porcelain=v2 --branch` yields them all: without branch and
|
|
455
|
+
// status there is no useful GitInfo, so a failed core fetch fails the whole
|
|
456
|
+
// outcome rather than dressing up as a clean repo on a fallback branch.
|
|
457
|
+
const core = await this.getCoreAsync(gitDir);
|
|
353
458
|
if (core.kind !== "ok") return core;
|
|
354
|
-
const aheadBehind = await this.getAheadBehindAsync(gitDir);
|
|
355
459
|
|
|
356
460
|
const result: GitInfo = {
|
|
357
461
|
branch: core.value.branch,
|
|
358
462
|
status: core.value.status,
|
|
359
|
-
aheadBehind,
|
|
463
|
+
aheadBehind: core.value.aheadBehind,
|
|
360
464
|
};
|
|
361
465
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
466
|
+
// sha, upstream, and the worktree counts all rode in on the core call —
|
|
467
|
+
// attaching them here is a memory read, not another spawn.
|
|
468
|
+
if (options.showWorkingTree) result.workingTree = core.value.workingTree;
|
|
469
|
+
if (options.showSha) result.sha = core.value.sha;
|
|
470
|
+
if (options.showUpstream) result.upstream = core.value.upstream;
|
|
365
471
|
|
|
366
472
|
// Heavy operations stay serial — each is an expensive git invocation and
|
|
367
473
|
// running them one at a time bounds concurrent git load per fetch.
|
|
368
|
-
if (options.showSha) {
|
|
369
|
-
result.sha = await this.getShaAsync(gitDir);
|
|
370
|
-
}
|
|
371
474
|
if (options.showTag) {
|
|
372
475
|
result.tag = await this.getNearestTagAsync(gitDir);
|
|
373
476
|
}
|
|
@@ -378,13 +481,11 @@ export class GitService {
|
|
|
378
481
|
// Light operations run in parallel. Helpers never reject — failure is a
|
|
379
482
|
// value in the outcome — so plain Promise.all replaces the allSettled +
|
|
380
483
|
// untyped resultMap machinery the swallowing design required.
|
|
381
|
-
const [stashCount,
|
|
484
|
+
const [stashCount, repoName] = await Promise.all([
|
|
382
485
|
options.showStashCount ? this.getStashCountAsync(gitDir) : undefined,
|
|
383
|
-
options.showUpstream ? this.getUpstreamAsync(gitDir) : undefined,
|
|
384
486
|
options.showRepoName ? this.getRepoNameAsync(gitDir) : undefined,
|
|
385
487
|
]);
|
|
386
488
|
if (stashCount !== undefined) result.stashCount = stashCount;
|
|
387
|
-
if (upstream !== undefined) result.upstream = upstream;
|
|
388
489
|
if (repoName !== undefined) {
|
|
389
490
|
result.repoName = repoName;
|
|
390
491
|
result.isWorktree = isWorktreeDir;
|
|
@@ -397,20 +498,6 @@ export class GitService {
|
|
|
397
498
|
return ok(result);
|
|
398
499
|
}
|
|
399
500
|
|
|
400
|
-
private async getShaAsync(workingDir: string): Promise<Outcome<string>> {
|
|
401
|
-
// non-zero = no HEAD to resolve (empty repo) — a domain answer.
|
|
402
|
-
return nonEmpty(
|
|
403
|
-
classify(
|
|
404
|
-
"git rev-parse HEAD",
|
|
405
|
-
await this.execGitAsync(["rev-parse", "--short=7", "HEAD"], {
|
|
406
|
-
cwd: workingDir,
|
|
407
|
-
timeout: 2000,
|
|
408
|
-
}),
|
|
409
|
-
"absent",
|
|
410
|
-
),
|
|
411
|
-
);
|
|
412
|
-
}
|
|
413
|
-
|
|
414
501
|
// [LAW:locality-or-seam] Public so the daemon-side provider can watch the
|
|
415
502
|
// real HEAD/index files even for git worktrees. For a regular repo this is
|
|
416
503
|
// `<workingDir>/.git`. For a worktree, `<workingDir>/.git` is a *file*
|
|
@@ -521,20 +608,6 @@ export class GitService {
|
|
|
521
608
|
return ok(stashList ? stashList.split("\n").length : 0);
|
|
522
609
|
}
|
|
523
610
|
|
|
524
|
-
private async getUpstreamAsync(workingDir: string): Promise<Outcome<string>> {
|
|
525
|
-
// non-zero = no upstream configured — the everyday domain answer.
|
|
526
|
-
return nonEmpty(
|
|
527
|
-
classify(
|
|
528
|
-
"git rev-parse @{u}",
|
|
529
|
-
await this.execGitAsync(["rev-parse", "--abbrev-ref", "@{u}"], {
|
|
530
|
-
cwd: workingDir,
|
|
531
|
-
timeout: 2000,
|
|
532
|
-
}),
|
|
533
|
-
"absent",
|
|
534
|
-
),
|
|
535
|
-
);
|
|
536
|
-
}
|
|
537
|
-
|
|
538
611
|
private async getRepoNameAsync(workingDir: string): Promise<Outcome<string>> {
|
|
539
612
|
const r = classify(
|
|
540
613
|
"git config remote.origin.url",
|
|
@@ -647,17 +720,16 @@ export class GitService {
|
|
|
647
720
|
}
|
|
648
721
|
}
|
|
649
722
|
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
debug(`[GIT-EXEC] Running git status in ${workingDir}`);
|
|
723
|
+
// [LAW:single-enforcer] The one core git read. `git status --porcelain=v2
|
|
724
|
+
// --branch` reports branch, short SHA, upstream, ahead/behind, and worktree
|
|
725
|
+
// status in a single subprocess — the fan-out this method replaces spawned up
|
|
726
|
+
// to six (status -b, two rev-list, a branch fallback, rev-parse HEAD, rev-parse
|
|
727
|
+
// @{u}). Parsing is delegated to the pure `parseStatusV2`.
|
|
728
|
+
private async getCoreAsync(workingDir: string): Promise<Outcome<CoreStatus>> {
|
|
729
|
+
debug(`[GIT-EXEC] Running git status --porcelain=v2 in ${workingDir}`);
|
|
658
730
|
const r = classify(
|
|
659
|
-
"git status --porcelain
|
|
660
|
-
await this.execGitAsync(["status", "--porcelain", "
|
|
731
|
+
"git status --porcelain=v2 --branch",
|
|
732
|
+
await this.execGitAsync(["status", "--porcelain=v2", "--branch"], {
|
|
661
733
|
cwd: workingDir,
|
|
662
734
|
timeout: 2000,
|
|
663
735
|
}),
|
|
@@ -666,124 +738,6 @@ export class GitService {
|
|
|
666
738
|
"failed",
|
|
667
739
|
);
|
|
668
740
|
if (r.kind !== "ok") return r;
|
|
669
|
-
|
|
670
|
-
const lines = r.value.split("\n");
|
|
671
|
-
|
|
672
|
-
let branch: string | null = null;
|
|
673
|
-
let status: "clean" | "dirty" | "conflicts" = "clean";
|
|
674
|
-
let staged = 0;
|
|
675
|
-
let unstaged = 0;
|
|
676
|
-
let untracked = 0;
|
|
677
|
-
let conflicts = 0;
|
|
678
|
-
|
|
679
|
-
for (const line of lines) {
|
|
680
|
-
if (!line) continue;
|
|
681
|
-
|
|
682
|
-
if (line.startsWith("## ")) {
|
|
683
|
-
const branchLine = line.substring(3);
|
|
684
|
-
const branchMatch = branchLine.split("...")[0];
|
|
685
|
-
if (branchMatch && branchMatch !== "HEAD (no branch)") {
|
|
686
|
-
branch = branchMatch;
|
|
687
|
-
}
|
|
688
|
-
continue;
|
|
689
|
-
}
|
|
690
|
-
|
|
691
|
-
if (line.length >= 2) {
|
|
692
|
-
const indexStatus = line.charAt(0);
|
|
693
|
-
const worktreeStatus = line.charAt(1);
|
|
694
|
-
|
|
695
|
-
if (indexStatus === "?" && worktreeStatus === "?") {
|
|
696
|
-
untracked++;
|
|
697
|
-
if (status === "clean") status = "dirty";
|
|
698
|
-
continue;
|
|
699
|
-
}
|
|
700
|
-
|
|
701
|
-
const statusPair = indexStatus + worktreeStatus;
|
|
702
|
-
if (["DD", "AU", "UD", "UA", "DU", "AA", "UU"].includes(statusPair)) {
|
|
703
|
-
conflicts++;
|
|
704
|
-
status = "conflicts";
|
|
705
|
-
continue;
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
if (indexStatus !== " " && indexStatus !== "?") {
|
|
709
|
-
staged++;
|
|
710
|
-
if (status === "clean") status = "dirty";
|
|
711
|
-
}
|
|
712
|
-
if (worktreeStatus !== " " && worktreeStatus !== "?") {
|
|
713
|
-
unstaged++;
|
|
714
|
-
if (status === "clean") status = "dirty";
|
|
715
|
-
}
|
|
716
|
-
}
|
|
717
|
-
}
|
|
718
|
-
|
|
719
|
-
if (branch === null) {
|
|
720
|
-
const fallback = await this.getFallbackBranch(workingDir);
|
|
721
|
-
// A transport failure resolving the branch fails the core: rendering
|
|
722
|
-
// a fake "detached" for a repo whose branch merely couldn't be read
|
|
723
|
-
// would be the same meaning-erasure this type exists to forbid.
|
|
724
|
-
if (fallback.kind === "failed") return fallback;
|
|
725
|
-
branch = fallback.kind === "ok" ? fallback.value : "detached";
|
|
726
|
-
}
|
|
727
|
-
|
|
728
|
-
return ok({
|
|
729
|
-
branch,
|
|
730
|
-
status,
|
|
731
|
-
workingTree: { staged, unstaged, untracked, conflicts },
|
|
732
|
-
});
|
|
733
|
-
}
|
|
734
|
-
|
|
735
|
-
private async getFallbackBranch(
|
|
736
|
-
workingDir: string,
|
|
737
|
-
): Promise<Outcome<string>> {
|
|
738
|
-
// Both commands answer "detached" with a non-zero exit (symbolic-ref) or
|
|
739
|
-
// empty output (show-current) — `absent` means genuinely detached.
|
|
740
|
-
const primary = nonEmpty(
|
|
741
|
-
classify(
|
|
742
|
-
"git branch --show-current",
|
|
743
|
-
await this.execGitAsync(["branch", "--show-current"], {
|
|
744
|
-
cwd: workingDir,
|
|
745
|
-
timeout: 2000,
|
|
746
|
-
}),
|
|
747
|
-
"absent",
|
|
748
|
-
),
|
|
749
|
-
);
|
|
750
|
-
if (primary.kind !== "absent") return primary;
|
|
751
|
-
return nonEmpty(
|
|
752
|
-
classify(
|
|
753
|
-
"git symbolic-ref HEAD",
|
|
754
|
-
await this.execGitAsync(["symbolic-ref", "--short", "HEAD"], {
|
|
755
|
-
cwd: workingDir,
|
|
756
|
-
timeout: 2000,
|
|
757
|
-
}),
|
|
758
|
-
"absent",
|
|
759
|
-
),
|
|
760
|
-
);
|
|
761
|
-
}
|
|
762
|
-
|
|
763
|
-
private async getAheadBehindAsync(
|
|
764
|
-
workingDir: string,
|
|
765
|
-
): Promise<Outcome<AheadBehind>> {
|
|
766
|
-
debug(`[GIT-EXEC] Running git ahead/behind in ${workingDir}`);
|
|
767
|
-
const [aheadResult, behindResult] = await Promise.all([
|
|
768
|
-
this.execGitAsync(["rev-list", "--count", "@{u}..HEAD"], {
|
|
769
|
-
cwd: workingDir,
|
|
770
|
-
timeout: 2000,
|
|
771
|
-
}),
|
|
772
|
-
this.execGitAsync(["rev-list", "--count", "HEAD..@{u}"], {
|
|
773
|
-
cwd: workingDir,
|
|
774
|
-
timeout: 2000,
|
|
775
|
-
}),
|
|
776
|
-
]);
|
|
777
|
-
// non-zero = no upstream to compare against — the domain answer for any
|
|
778
|
-
// local-only branch, distinct from a transport failure.
|
|
779
|
-
const ahead = classify("git rev-list @{u}..HEAD", aheadResult, "absent");
|
|
780
|
-
const behind = classify("git rev-list HEAD..@{u}", behindResult, "absent");
|
|
781
|
-
if (ahead.kind === "failed") return ahead;
|
|
782
|
-
if (behind.kind === "failed") return behind;
|
|
783
|
-
if (ahead.kind === "absent" || behind.kind === "absent") return ABSENT;
|
|
784
|
-
return ok({
|
|
785
|
-
ahead: parseInt(ahead.value.trim()) || 0,
|
|
786
|
-
behind: parseInt(behind.value.trim()) || 0,
|
|
787
|
-
});
|
|
741
|
+
return ok(parseStatusV2(r.value));
|
|
788
742
|
}
|
|
789
743
|
}
|