@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@promptctl/cc-candybar",
3
- "version": "1.18.0",
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.0",
99
- "@promptctl/cc-candybar-darwin-x64": "1.18.0",
100
- "@promptctl/cc-candybar-linux-x64": "1.18.0",
101
- "@promptctl/cc-candybar-linux-arm64": "1.18.0"
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
- const DEBOUNCE_MS = 50;
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.
@@ -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: without them there is no useful GitInfo,
350
- // so a failed core fetch fails the whole outcome rather than dressing
351
- // up as a clean repo on a fallback branch.
352
- const core = await this.getStatusWithBranchAsync(gitDir);
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
- if (options.showWorkingTree) {
363
- result.workingTree = core.value.workingTree;
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, upstream, repoName] = await Promise.all([
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
- private async getStatusWithBranchAsync(workingDir: string): Promise<
651
- Outcome<{
652
- branch: string;
653
- status: "clean" | "dirty" | "conflicts";
654
- workingTree: WorkingTree;
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 -b",
660
- await this.execGitAsync(["status", "--porcelain", "-b"], {
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
  }