@promptctl/cc-candybar 1.17.5 → 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/config/default-dsl-config.ts +39 -17
- 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.
|
|
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.
|
|
99
|
-
"@promptctl/cc-candybar-darwin-x64": "1.
|
|
100
|
-
"@promptctl/cc-candybar-linux-x64": "1.
|
|
101
|
-
"@promptctl/cc-candybar-linux-arm64": "1.
|
|
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": {
|
|
@@ -627,15 +627,18 @@ export const DEFAULT_DSL_CONFIG = {
|
|
|
627
627
|
fg: "foreground",
|
|
628
628
|
when: '{{ or (ne .git.prUrl "") (ne .git.prError "") }}',
|
|
629
629
|
},
|
|
630
|
-
// Quick-action tray —
|
|
631
|
-
//
|
|
632
|
-
//
|
|
633
|
-
//
|
|
634
|
-
//
|
|
635
|
-
//
|
|
630
|
+
// Quick-action tray — the default bar's interactivity: copy the session id,
|
|
631
|
+
// open the project dir / transcript (this session's jsonl) in the editor.
|
|
632
|
+
// (copyDir — copy the cwd — stays declared as an action below for users who
|
|
633
|
+
// want a fourth glyph; it is simply not in the default tray.)
|
|
634
|
+
// [LAW:locality-or-seam] The glyph is the REPRESENTATION; the named action
|
|
635
|
+
// (below) is the BEHAVIOR; the action name is the seam between them. Re-glyph
|
|
636
|
+
// without touching behavior; re-target without touching this template. Each
|
|
637
|
+
// `{{ action … }}` emits one OSC-8 clickable region whose URL the wire codec
|
|
638
|
+
// owns end-to-end.
|
|
636
639
|
toolbar: {
|
|
637
640
|
template:
|
|
638
|
-
'{{ action "copySession" "⎘ id" }}
|
|
641
|
+
'{{ action "copySession" "⎘ id" }}' +
|
|
639
642
|
' {{ action "openProject" "↗ proj" }} {{ action "openTranscript" "↗ log" }}',
|
|
640
643
|
bg: "surface",
|
|
641
644
|
fg: "foreground",
|
|
@@ -808,11 +811,24 @@ export const DEFAULT_DSL_CONFIG = {
|
|
|
808
811
|
// Default layout — the canonical LayoutNode tree (`satisfies DslConfig`
|
|
809
812
|
// requires the lowered form here; the terse Option-A `{ h/v/seg }` grammar is
|
|
810
813
|
// the loader's authoring surface for user JSON, not this typed literal).
|
|
811
|
-
//
|
|
812
|
-
//
|
|
813
|
-
//
|
|
814
|
-
//
|
|
815
|
-
//
|
|
814
|
+
//
|
|
815
|
+
// Two rows stacked by the vertical container: an IDENTITY + ACTIONS row
|
|
816
|
+
// (where am I / what can I do here — the directory, the verbose `gitaculous`
|
|
817
|
+
// line (repo, sha, working-tree, upstream, stash, time-since-commit), then the
|
|
818
|
+
// quick-action tray: copy session id, open project / transcript in the editor)
|
|
819
|
+
// over a STATUS row (what's happening now — model, context-window fill,
|
|
820
|
+
// prompt-cache warmth, and the 5h / 7d rate-limit quotas). The tray sits on
|
|
821
|
+
// the identity row because its actions are workspace-scoped (this session,
|
|
822
|
+
// this project), not usage metrics. Each row zips its segments through the
|
|
823
|
+
// powerline joiner; `\n` separates the rows.
|
|
824
|
+
//
|
|
825
|
+
// [LAW:dataflow-not-control-flow] Every status segment is when-gated on its
|
|
826
|
+
// own signal (no repo → the identity row is just the directory + tray; no
|
|
827
|
+
// active rate-limit window → block/weekly drop; no cache activity → cacheTimer
|
|
828
|
+
// drops). A row therefore only ever shows the segments that have real data —
|
|
829
|
+
// the layout is chosen by the data, not by branches — so the default never
|
|
830
|
+
// paints an empty or placeholder cell. The directory and the tray have no
|
|
831
|
+
// `when`, so row 1 always anchors the bar.
|
|
816
832
|
root: {
|
|
817
833
|
kind: "container",
|
|
818
834
|
direction: "vertical",
|
|
@@ -822,13 +838,19 @@ export const DEFAULT_DSL_CONFIG = {
|
|
|
822
838
|
direction: "horizontal",
|
|
823
839
|
children: [
|
|
824
840
|
{ kind: "segment", name: "directory" },
|
|
825
|
-
{ kind: "segment", name: "
|
|
841
|
+
{ kind: "segment", name: "gitaculous" },
|
|
842
|
+
{ kind: "segment", name: "toolbar" },
|
|
843
|
+
],
|
|
844
|
+
},
|
|
845
|
+
{
|
|
846
|
+
kind: "container",
|
|
847
|
+
direction: "horizontal",
|
|
848
|
+
children: [
|
|
826
849
|
{ kind: "segment", name: "model" },
|
|
827
|
-
{ kind: "segment", name: "session" },
|
|
828
|
-
{ kind: "segment", name: "today" },
|
|
829
850
|
{ kind: "segment", name: "context" },
|
|
830
|
-
{ kind: "segment", name: "
|
|
831
|
-
{ kind: "segment", name: "
|
|
851
|
+
{ kind: "segment", name: "cacheTimer" },
|
|
852
|
+
{ kind: "segment", name: "block" },
|
|
853
|
+
{ kind: "segment", name: "weekly" },
|
|
832
854
|
],
|
|
833
855
|
},
|
|
834
856
|
],
|
|
@@ -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
|
}
|