@promptctl/cc-candybar 1.42.1 → 1.43.0

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.
Files changed (126) hide show
  1. package/dist/index.mjs +72 -71
  2. package/package.json +5 -6
  3. package/src/check.ts +0 -478
  4. package/src/cli-flags.ts +0 -8
  5. package/src/click/wire.ts +0 -158
  6. package/src/config/action.ts +0 -329
  7. package/src/config/cli.ts +0 -71
  8. package/src/config/default-dsl-config.ts +0 -1645
  9. package/src/config/disclosure.ts +0 -170
  10. package/src/config/dsl-loader.ts +0 -339
  11. package/src/config/dsl-types.ts +0 -581
  12. package/src/config/edit-chrome.ts +0 -559
  13. package/src/config/help.ts +0 -151
  14. package/src/config/ident.ts +0 -22
  15. package/src/config/layout-ops.ts +0 -177
  16. package/src/config/loader/actions.ts +0 -972
  17. package/src/config/loader/cache.ts +0 -206
  18. package/src/config/loader/cross-ref.ts +0 -714
  19. package/src/config/loader/cycles.ts +0 -148
  20. package/src/config/loader/diagnostics.ts +0 -99
  21. package/src/config/loader/discovery.ts +0 -182
  22. package/src/config/loader/edit-mode.ts +0 -137
  23. package/src/config/loader/emit-schema.ts +0 -68
  24. package/src/config/loader/globals.ts +0 -269
  25. package/src/config/loader/helpers.ts +0 -48
  26. package/src/config/loader/layout.ts +0 -693
  27. package/src/config/loader/looks.ts +0 -96
  28. package/src/config/loader/menu-synth.ts +0 -435
  29. package/src/config/loader/merge.ts +0 -115
  30. package/src/config/loader/persist-target.ts +0 -67
  31. package/src/config/loader/presets.ts +0 -119
  32. package/src/config/loader/refs.ts +0 -100
  33. package/src/config/loader/reserved-namespace.ts +0 -38
  34. package/src/config/loader/segments.ts +0 -120
  35. package/src/config/loader/validate-core.ts +0 -737
  36. package/src/config/loader/variables.ts +0 -260
  37. package/src/config/menu-keys.ts +0 -139
  38. package/src/config/option-domain.ts +0 -164
  39. package/src/config/presets.ts +0 -326
  40. package/src/config/settings-menu.ts +0 -775
  41. package/src/daemon/acquire.ts +0 -684
  42. package/src/daemon/cache/git.ts +0 -649
  43. package/src/daemon/cache/render.ts +0 -623
  44. package/src/daemon/cache/session-usage-store.ts +0 -720
  45. package/src/daemon/cache/watchers.ts +0 -249
  46. package/src/daemon/client-debug.ts +0 -120
  47. package/src/daemon/client-stats.ts +0 -130
  48. package/src/daemon/client-transport.ts +0 -273
  49. package/src/daemon/client.ts +0 -78
  50. package/src/daemon/config-overrides-store.ts +0 -663
  51. package/src/daemon/debug-types.ts +0 -91
  52. package/src/daemon/debug.ts +0 -264
  53. package/src/daemon/fork-bomb-breaker.ts +0 -351
  54. package/src/daemon/limits.ts +0 -211
  55. package/src/daemon/log.ts +0 -81
  56. package/src/daemon/parent-watchdog.ts +0 -87
  57. package/src/daemon/paths.ts +0 -211
  58. package/src/daemon/process-fingerprint.ts +0 -146
  59. package/src/daemon/protocol.ts +0 -292
  60. package/src/daemon/render-payload.ts +0 -1256
  61. package/src/daemon/server.ts +0 -1330
  62. package/src/daemon/session-state-file.ts +0 -108
  63. package/src/daemon/session-state.ts +0 -237
  64. package/src/daemon/socket-lease.ts +0 -209
  65. package/src/daemon/socket-ownership.ts +0 -209
  66. package/src/daemon/stats.ts +0 -235
  67. package/src/daemon/verbs/config-validators.ts +0 -250
  68. package/src/daemon/verbs/index.ts +0 -706
  69. package/src/daemon/verbs/state-validators.ts +0 -249
  70. package/src/daemon/verbs/validator-registry.ts +0 -457
  71. package/src/demo/dsl.ts +0 -143
  72. package/src/demo/mock-data.ts +0 -67
  73. package/src/demo/statusline.json5 +0 -94
  74. package/src/dsl/node-registry.ts +0 -374
  75. package/src/dsl/render.ts +0 -803
  76. package/src/help-text.ts +0 -90
  77. package/src/index.ts +0 -210
  78. package/src/install/currency.ts +0 -197
  79. package/src/install/index.ts +0 -557
  80. package/src/proc/launch.ts +0 -459
  81. package/src/proc/stats-handle.ts +0 -13
  82. package/src/render/action.ts +0 -883
  83. package/src/render/active-segment.ts +0 -78
  84. package/src/render/diagnostic-style.ts +0 -23
  85. package/src/render/diagnostic-text.ts +0 -77
  86. package/src/render/error-glyph.ts +0 -53
  87. package/src/render/menu.ts +0 -257
  88. package/src/render/outcome-plan.ts +0 -45
  89. package/src/render/picker.ts +0 -372
  90. package/src/render/segment-color.ts +0 -74
  91. package/src/render/split-lines.ts +0 -51
  92. package/src/render/strip.ts +0 -228
  93. package/src/segments/cache.ts +0 -131
  94. package/src/segments/context.ts +0 -190
  95. package/src/segments/git.ts +0 -1084
  96. package/src/segments/metrics.ts +0 -187
  97. package/src/segments/pricing.ts +0 -452
  98. package/src/segments/session.ts +0 -23
  99. package/src/segments/tmux.ts +0 -74
  100. package/src/template-engine/cells.ts +0 -90
  101. package/src/template-engine/colors.ts +0 -124
  102. package/src/template-engine/engine.ts +0 -108
  103. package/src/template-engine/funcs.ts +0 -232
  104. package/src/template-engine/index.ts +0 -11
  105. package/src/template-engine/layout.ts +0 -133
  106. package/src/template-engine/scope.ts +0 -62
  107. package/src/template-engine/sparkline.ts +0 -79
  108. package/src/themes/index.ts +0 -20
  109. package/src/themes/palette-resolvers.ts +0 -84
  110. package/src/themes/policy.ts +0 -393
  111. package/src/utils/cache.ts +0 -206
  112. package/src/utils/claude.ts +0 -683
  113. package/src/utils/color-support.ts +0 -118
  114. package/src/utils/formatters.ts +0 -99
  115. package/src/utils/logger.ts +0 -5
  116. package/src/utils/outcome.ts +0 -33
  117. package/src/utils/schema-validator.ts +0 -126
  118. package/src/utils/single-flight.ts +0 -57
  119. package/src/utils/terminal-width.ts +0 -51
  120. package/src/utils/terminal.ts +0 -11
  121. package/src/utils/transcript-fs.ts +0 -279
  122. package/src/var-system/index.ts +0 -24
  123. package/src/var-system/sources.ts +0 -1047
  124. package/src/var-system/store.ts +0 -223
  125. package/src/var-system/types.ts +0 -57
  126. package/src/version.ts +0 -17
@@ -1,1084 +0,0 @@
1
- import fs from "node:fs";
2
- import path from "node:path";
3
- import { launch, type LaunchResult } from "../proc/launch";
4
- import { ABSENT, failed, ok, type Outcome } from "../utils/outcome";
5
- import { debug } from "../utils/logger";
6
-
7
- export interface WorkingTree {
8
- staged: number;
9
- unstaged: number;
10
- untracked: number;
11
- conflicts: number;
12
- }
13
-
14
- export interface AheadBehind {
15
- ahead: number;
16
- behind: number;
17
- }
18
-
19
- // [LAW:types-are-the-program] The branch's open PR/MR as the forge reports it.
20
- // `number` and `url` are the click target; `state` is the forge's status string
21
- // (GitHub "OPEN", GitLab "opened") — carried so a consumer can color/label it,
22
- // though resolvePullRequest only ever returns a PR whose state is open (a
23
- // merged/closed PR for the branch is the domain's `absent`, not a value).
24
- export interface PullRequest {
25
- number: number;
26
- state: string;
27
- url: string;
28
- }
29
-
30
- // [LAW:types-are-the-program] Every on-demand field is an Outcome, so "this
31
- // value is unknown because the fetch failed" is representable distinct from
32
- // a real 0/""/basename — the states the old catch-and-substitute blocks
33
- // erased. An undefined field means "not requested" (its `show*` flag was
34
- // off); `absent` means the domain genuinely has none (no upstream, no tags,
35
- // no stash); `failed` carries the reason to the consuming boundary, which
36
- // owns the log effect. branch/status stay plain: a fetch that cannot
37
- // determine them is a failed fetch, not a GitInfo.
38
- export interface GitInfo {
39
- branch: string;
40
- status: "clean" | "dirty" | "conflicts";
41
- aheadBehind: Outcome<AheadBehind>;
42
- workingTree?: WorkingTree;
43
- sha?: Outcome<string>;
44
- operation?: Outcome<string>;
45
- tag?: Outcome<string>;
46
- timeSinceCommit?: Outcome<number>;
47
- stashCount?: Outcome<number>;
48
- upstream?: Outcome<string>;
49
- repoName?: Outcome<string>;
50
- // The repo's browsable web page, derived from the same remotes read repoName
51
- // is. `absent` = the repo has no remote a browser can open (local-only, a
52
- // bare-path remote); `failed` = the remotes read itself failed.
53
- repoUrl?: Outcome<string>;
54
- isWorktree?: boolean;
55
- // [LAW:no-silent-failure] The forge lookup's three outcomes are all kept
56
- // distinct here: `ok` is an open PR, `absent` is "this branch has none / no
57
- // forge / no forge CLI", `failed` is "the forge was asked but couldn't
58
- // answer" (auth, network, API error). The render boundary surfaces `failed`
59
- // as a VISIBLE marker — collapsing it to `absent` would make a transient
60
- // outage look like the PR vanished. Undefined = `showPullRequest` was off.
61
- pullRequest?: Outcome<PullRequest>;
62
- }
63
-
64
- // [LAW:one-source-of-truth] The one shape of getGitInfo's `show*` toggles. Each
65
- // flag opts into an extra git invocation; an unset flag leaves its GitInfo field
66
- // undefined (not requested). Every caller that builds these options
67
- // (render-payload's gitOptionsFromClosure, the cache override) references THIS
68
- // type, so the toggle set cannot drift between producer and consumer.
69
- export interface GitInfoOptions {
70
- showSha?: boolean;
71
- showWorkingTree?: boolean;
72
- showOperation?: boolean;
73
- showTag?: boolean;
74
- showTimeSinceCommit?: boolean;
75
- showStashCount?: boolean;
76
- showUpstream?: boolean;
77
- showRepoName?: boolean;
78
- // Shares ONE `git config --get-regexp` with showRepoName — turning both on
79
- // costs the same single spawn as turning either on alone.
80
- showRepoUrl?: boolean;
81
- // Opts into the forge (gh/glab) PR/MR lookup — a network call, so it is the
82
- // one option whose fetch the daemon caches on a longer, independent TTL than
83
- // the rest of GitInfo (see src/daemon/cache/git.ts). Never resolved by the
84
- // inner GitService's computeGitInfo; the cache layer owns the lookup+cache.
85
- showPullRequest?: boolean;
86
- }
87
-
88
- // [LAW:dataflow-not-control-flow] One classifier for every git invocation.
89
- // Whether a non-zero exit is the domain answering "there is none" (describe
90
- // with no tags, rev-parse @{u} with no upstream) or a real failure is
91
- // per-command knowledge — it enters here as data, not as a catch block at
92
- // every callsite. Transport failures (timeout, spawn error, signal) are
93
- // always `failed`: git did not answer.
94
- function classify(
95
- label: string,
96
- result: LaunchResult,
97
- // How this command spells "there is none":
98
- // "absent" — ANY non-zero exit is the domain answer. `git describe --tags`
99
- // and `git rev-parse @{u}` both exit 128 for their genuine absences, so a
100
- // narrower rule would misread them as failures.
101
- // a number — ONLY that exit code is the domain answer; every other non-zero
102
- // is a real failure. `git config --get-regexp` exits 1 for "no matches"
103
- // but 128 for an unreadable config, and folding those together would
104
- // render a broken repo as an empty one. [LAW:no-silent-failure]
105
- // "failed" — no non-zero exit is ever a domain answer.
106
- nonZero: "absent" | "failed" | number,
107
- ): Outcome<string> {
108
- if (result.ok) return ok(result.stdout);
109
- if (result.reason === "non-zero" && nonZero === "absent") return ABSENT;
110
- if (result.reason === "non-zero" && nonZero === result.exitCode)
111
- return ABSENT;
112
- const detail = [
113
- result.reason,
114
- result.exitCode != null ? `exit ${result.exitCode}` : null,
115
- result.error ?? firstLine(result.stderr),
116
- ]
117
- .filter(Boolean)
118
- .join(", ");
119
- return failed(`${label}: ${detail}`);
120
- }
121
-
122
- function firstLine(s: string): string {
123
- return s.trim().split("\n", 1)[0] ?? "";
124
- }
125
-
126
- // [LAW:dataflow-not-control-flow] Lift a pure, nullable derivation onto the
127
- // Outcome it derives from, in one total fold: a read that failed stays failed
128
- // (its reason survives to the boundary), and a derivation that found nothing
129
- // becomes the domain's `absent`. Callers get a derived field whose three states
130
- // line up with the read's, without re-deciding the policy at each site.
131
- function derived<A, B>(
132
- from: Outcome<A>,
133
- project: (value: A) => B | null,
134
- ): Outcome<B> {
135
- if (from.kind !== "ok") return from;
136
- const value = project(from.value);
137
- return value === null ? ABSENT : ok(value);
138
- }
139
-
140
- // Trim an ok stdout; an empty answer is the domain's "there is none".
141
- function nonEmpty(o: Outcome<string>): Outcome<string> {
142
- if (o.kind !== "ok") return o;
143
- const v = o.value.trim();
144
- return v ? ok(v) : ABSENT;
145
- }
146
-
147
- // [LAW:one-type-per-behavior] `gh` and `glab` are two instances of one act:
148
- // "ask a forge CLI for the branch's PR, fold the typed launch result into an
149
- // Outcome<PullRequest>." The accept/reject shape table is identical across
150
- // both — only the no-PR stderr signature and the JSON field names differ — so
151
- // the classification lives here once and each forge supplies its own
152
- // (noPrPattern, parse) as data.
153
- //
154
- // [LAW:no-silent-failure] The full shape table, enumerated so no input leaks:
155
- // ok + parse ok (open PR) → ok (the value)
156
- // ok + parse ok (not open) → absent (branch's PR is done)
157
- // ok + parse fails → failed (forge answered garbage)
158
- // non-zero + no-PR stderr → absent (genuine "none for branch")
159
- // spawn-error ENOENT (no CLI) → absent (no forge integration)
160
- // spawn-error other (EACCES, …) → failed (CLI present but unlaunchable)
161
- // non-zero (auth/net/not-a-repo) → failed (forge couldn't answer)
162
- // timeout / signal / rate-limited → failed (forge couldn't answer)
163
- export type ForgeName = "github" | "gitlab";
164
-
165
- // [LAW:types-are-the-program] A git remote decomposed into the four facts every
166
- // consumer of one actually wants. Credentials are absent by construction — the
167
- // parser never carries userinfo out — so no downstream can leak a token it was
168
- // never handed.
169
- export interface RemoteRef {
170
- // Lowercase, no trailing colon: "https", "ssh", "git", "file", …
171
- readonly scheme: string;
172
- // Lowercased; "" for a hostless URL (`file:///srv/git/r`).
173
- readonly host: string;
174
- // "" when the remote names none.
175
- readonly port: string;
176
- // No leading or trailing slash. Still carries any `.git` suffix — trimming
177
- // that is a web-display rule, not a fact about the remote.
178
- readonly path: string;
179
- }
180
-
181
- // A DOS drive path is a LOCAL path, not `host:path`. git says so directly
182
- // (`has_dos_drive_prefix`), and without this the scp arm below claims the drive
183
- // letter as a hostname: `C:/repo.git` became `https://C/repo`, a live link to a
184
- // host named `c`.
185
- //
186
- // The separator is required. `^[A-Za-z]:` alone would also reject `h:repo.git`,
187
- // a single-letter ssh-config alias and a form people really use; requiring
188
- // `[\\/]` keeps that working while still catching `C:/…` and `C:\…`.
189
- //
190
- // [LAW:one-type-per-behavior] exception: git's own drive-letter handling is
191
- // compiled in only on Windows — on POSIX `git ls-remote "C:/x"` genuinely tries
192
- // ssh host `c` — so rejecting unconditionally is a deliberate small infidelity
193
- // to the producer. Reading `path.sep` here would make a pure parser ambient
194
- // (`[LAW:effects-at-boundaries]`), and the asymmetry pays for it: on POSIX the
195
- // only input whose answer changes is a single-letter host with a drive-shaped
196
- // absolute path, which in practice is a pasted Windows path. No link beats a
197
- // wrong link.
198
- const DOS_DRIVE_PATH = /^[A-Za-z]:[\\/]/;
199
-
200
- // [LAW:single-enforcer] THE one decision of what shape a raw remote string is.
201
- // Both questions asked of a remote — "which forge is this?" (`remoteHost` →
202
- // `detectForge`) and "what page does this open?" (`remoteWebUrl`) — are
203
- // projections over this one answer, so they cannot classify the same string
204
- // differently. They already had: two regexes ago, `detectForge` lowercased its
205
- // host and `remoteWebUrl` did not, because WHATWG normalizes host case for
206
- // "special" schemes (`https:`) and not for `ssh:` — so `git@GitHub.com:o/r.git`
207
- // resolved to forge `github` but to page `https://GitHub.com/o/r`. The
208
- // lowercase below is that fix, applied once where both readers see it.
209
- //
210
- // git spells an ssh remote two ways, and this collapses them: the URL form
211
- // `scheme://[user@]host[:port]/path`, and the scp shorthand `[user@]host:path`
212
- // — which per `git help clone` "is only recognized if there are no slashes
213
- // before the first colon". Rewriting the shorthand into its ssh:// spelling
214
- // means one parser (the URL parser) sees every shape.
215
- //
216
- // Returns null when the string names no host at all: a local path, a relative
217
- // path, a drive path, an empty remote.
218
- export function parseRemoteRef(raw: string): RemoteRef | null {
219
- const trimmed = raw.trim();
220
- if (DOS_DRIVE_PATH.test(trimmed)) return null;
221
-
222
- // git spells an scp host two ways and both must decode through here. The
223
- // bracketed IPv6 arm is tried FIRST because the generic arm would otherwise
224
- // stop at the first colon inside the brackets and claim `[2001` as the host.
225
- // Its user capture allows colons (`[^@/]+`) where the generic arm forbids
226
- // them: an IPv6 literal makes colons ordinary, so `user@[::1]:repo.git` must
227
- // still parse.
228
- //
229
- // The `(?!//)` is what keeps `https://…` out of the generic arm — there the
230
- // colon separates a scheme, not a host from a path. A single-slash
231
- // `file:/srv/x` deliberately DOES land here: git resolves it to ssh host
232
- // `file` too (verified with `git ls-remote`), and disagreeing with git about
233
- // what a repo's own remote means would be the worse answer.
234
- const scp =
235
- trimmed.match(/^(?:[^@/]+@)?(\[[^\]]+\]):(.*)$/) ??
236
- trimmed.match(/^(?:[^@/:]+@)?([^/:]+):(?!\/\/)(.*)$/);
237
- const candidate = scp
238
- ? `ssh://${scp[1]}/${scp[2]!.replace(/^\/+/, "")}`
239
- : trimmed;
240
-
241
- let url: URL;
242
- try {
243
- url = new URL(candidate);
244
- } catch {
245
- return null;
246
- }
247
-
248
- return {
249
- scheme: url.protocol.replace(/:$/, ""),
250
- host: url.hostname.toLowerCase(),
251
- port: url.port,
252
- path: url.pathname.replace(/^\/+/, "").replace(/\/+$/, ""),
253
- };
254
- }
255
-
256
- // The host `detectForge` dispatches on. A remote with no host (a `file://`
257
- // mirror) names no forge, same as an unparseable one.
258
- function remoteHost(remoteUrl: string): string | null {
259
- return parseRemoteRef(remoteUrl)?.host || null;
260
- }
261
-
262
- // [LAW:types-are-the-program] Branch on the HOST, not a substring of the whole
263
- // URL — a non-GitLab remote whose path merely contains "gitlab" (a repo named
264
- // `gitlab`) must not dispatch to glab. Self-hosted GitLab is detected by a
265
- // `gitlab.`-prefixed host label (gitlab.example.com); a GitLab on an arbitrary
266
- // hostname is undetectable here and falls through to null (absent), same as
267
- // GitHub Enterprise on a custom domain.
268
- export function detectForge(remoteUrl: string): ForgeName | null {
269
- const host = remoteHost(remoteUrl);
270
- if (!host) return null;
271
- if (host === "github.com" || host.endsWith(".github.com")) return "github";
272
- if (/(^|\.)gitlab\./.test(host)) return "gitlab";
273
- return null;
274
- }
275
-
276
- // [LAW:types-are-the-program] One remote exactly as git reports it: the name it
277
- // is configured under and its raw URL. The browsable page is deliberately NOT a
278
- // field here — it is a DERIVATION (`remoteWebUrl`), so the raw form stays the
279
- // single stored territory and every consumer draws its own map from it.
280
- export interface GitRemote {
281
- readonly name: string;
282
- // EVERY configured url, in config order. A remote genuinely has N of them
283
- // (a repo can fetch from a local mirror and push to a forge), and modelling
284
- // it as one was the lossy map: the discarded url was sometimes the only one
285
- // naming a forge, which cost both the repo link and the PR lookup.
286
- readonly urls: readonly string[];
287
- }
288
-
289
- // [LAW:effects-at-boundaries] Pure text→data over `git config --get-regexp
290
- // ^remote\..*\.url$` stdout, so the accept/reject table is unit-testable without
291
- // spawning git. Each line is `remote.<name>.url <url>`; the name capture is
292
- // greedy so a dotted remote name (`remote.my.fork.url` → `my.fork`) keeps its
293
- // dots. A line carrying no URL is a remote with no URL, the domain's own
294
- // "none", not a parse failure.
295
- //
296
- // Every url under a name is KEPT, in config order — see GitRemote. Which one
297
- // represents the repository is `identifyingUrl`'s decision, made where the
298
- // answer is used rather than by discarding data here.
299
- //
300
- // The read this parses is scoped `--local` (see getRemotesAsync), which is what
301
- // makes config order unambiguous: across merged scopes git lists system →
302
- // global → local, so an unscoped read would put the LEAST specific url first.
303
- // That precedence — not push-mirror ordering — is why the `git config --get`
304
- // this replaced returned the last value.
305
- export function parseRemotes(stdout: string): GitRemote[] {
306
- const urlsByName = new Map<string, string[]>();
307
- for (const line of stdout.split("\n")) {
308
- const match = line.match(/^remote\.(.+)\.url\s+(\S.*)$/);
309
- if (!match) continue;
310
- const urls = urlsByName.get(match[1]!) ?? [];
311
- urls.push(match[2]!.trim());
312
- urlsByName.set(match[1]!, urls);
313
- }
314
- return [...urlsByName].map(([name, urls]) => ({ name, urls }));
315
- }
316
-
317
- // [LAW:one-source-of-truth] THE url that says which repository a remote IS —
318
- // the source of its display name and its web page, and nothing else. The first
319
- // url a browser can open wins, else the first configured: a remote that fetches
320
- // from a local mirror and pushes to a forge keeps its forge identity, which is
321
- // the case that broke when only the first url survived.
322
- //
323
- // Forge dispatch is NOT this question and does not read this — see
324
- // `forgeRemoteUrl`. Identity asks "which repository is this?", dispatch asks
325
- // "where do I ask about pull requests?", and a remote naming two hosts has two
326
- // different correct answers.
327
- function identifyingUrl(remote: GitRemote): string | null {
328
- return (
329
- remote.urls.find((u) => remoteWebUrl(u) !== null) ?? remote.urls[0] ?? null
330
- );
331
- }
332
-
333
- // [LAW:one-source-of-truth] THE remote that represents this repo. `origin` is
334
- // git's own name for the canonical one, else the first configured. One
335
- // selection, so a repo's NAME and its LINK can never describe two different
336
- // repositories — before this, repoName read origin while repoWebUrl walked past
337
- // an unbrowsable origin to another remote, rendering `backup` beside a link to
338
- // someone else's `realname`.
339
- //
340
- // Note the selection ignores browsability on purpose: if origin is a local
341
- // mirror, that mirror IS this repo, and the honest render is its name with no
342
- // link. Linking to a different remote's page was the lie.
343
- function pickRepoRemote(remotes: readonly GitRemote[]): GitRemote | null {
344
- return remotes.find((r) => r.name === "origin") ?? remotes[0] ?? null;
345
- }
346
-
347
- // The identifying url of the remote that represents this repo — the one answer
348
- // repoName and repoUrl both project from.
349
- export function repoRemoteUrl(remotes: readonly GitRemote[]): string | null {
350
- const remote = pickRepoRemote(remotes);
351
- return remote ? identifyingUrl(remote) : null;
352
- }
353
-
354
- // [LAW:one-type-per-behavior] The url the forge lookup dispatches on, which is a
355
- // DIFFERENT question from which url identifies the repo: "what repository is
356
- // this?" (name, page) versus "where do I ask about pull requests?". They answer
357
- // the same in every single-url config — essentially all of them — and diverge
358
- // only when a remote genuinely names two hosts, which is exactly where one
359
- // answer cannot serve both.
360
- //
361
- // `detectForge` gates the entire PR lookup and returns ABSENT before `gh` or
362
- // `glab` is spawned, so a browsable-but-unrecognized mirror listed first (a
363
- // self-hosted Gitea before a GitHub url) would silently decide the branch has no
364
- // PR. Prefer a url a forge CLI recognizes; fall back to the identifying url so a
365
- // repo with no recognized forge still keys its cache on something stable.
366
- export function forgeRemoteUrl(remotes: readonly GitRemote[]): string | null {
367
- const remote = pickRepoRemote(remotes);
368
- if (!remote) return null;
369
- return (
370
- remote.urls.find((u) => detectForge(u) !== null) ?? identifyingUrl(remote)
371
- );
372
- }
373
-
374
- // The repository's name as its identifying url spells it. Reads the PARSED path
375
- // so the name and the page agree by construction — a raw-string regex disagreed
376
- // with the link for a slashless scp remote (`git@host:repo.git`) and for a
377
- // trailing-slash url, both of which fell through to the directory basename while
378
- // the link resolved fine.
379
- //
380
- // A local-path remote (`/srv/mirrors/backup.git`) has no parsed path but does
381
- // have a last segment, so the raw string is the fallback — the directory
382
- // basename stays reserved for its documented case, a repo with NO remote.
383
- export function repoNameFromUrl(url: string): string | null {
384
- const parsed = parseRemoteRef(url);
385
- // The raw branch must strip trailing separators the way `parseRemoteRef`
386
- // already does for the parsed one — otherwise `/srv/mirrors/backup.git/`
387
- // splits to a final empty segment and falls through to the directory
388
- // basename, which is reserved for a repo with NO remote.
389
- const segments = (parsed?.path ?? url.replace(/[\\/]+$/, "")).split("/");
390
- const name = (segments[segments.length - 1] ?? "").replace(/\.git$/, "");
391
- return name || null;
392
- }
393
-
394
- // [LAW:parse-dont-validate] Parse a git remote into the page a browser can open,
395
- // or nothing. The returned string IS the proof: it has been through the URL
396
- // parser, carries an http(s) scheme, has had any credentials stripped, and names
397
- // a repo path — so the render boundary links it without re-checking anything.
398
- //
399
- // [LAW:no-silent-failure] The whole accept/reject table, so no shape leaks:
400
- // https://host/o/r.git → https://host/o/r
401
- // https://tok@host/o/r → https://host/o/r (credential DROPPED)
402
- // http://gitea.lan:3000/o/r.git → http://gitea.lan:3000/o/r (web port kept)
403
- // git@host:o/r.git → https://host/o/r (scp shorthand)
404
- // ssh://git@host:2222/o/r.git → https://host/o/r (ssh port DROPPED —
405
- // an ssh port says nothing about the web one)
406
- // git://host/o/r.git → https://host/o/r
407
- // /srv/git/r.git · ../r · "" → null (names no host)
408
- // file:///srv/git/r → null (nothing serves it)
409
- // git@host: → null (a host with no repo path)
410
- // C:/r.git · C:\r.git → null (a drive path, not host:path)
411
- //
412
- // [LAW:one-type-per-behavior] The ssh→https transposition is host-agnostic BY
413
- // DESIGN. GitHub, GitLab, Gitea/Forgejo, Bitbucket, Codeberg and sr.ht are not
414
- // six types to enumerate — they are six INSTANCES of one convention: the web UI
415
- // lives at the same host and path as the ssh remote. A hostname allow-list could
416
- // only ever recognize the hosted ones, and would be blind to every self-hosted
417
- // forge, which is the case that needs this most. The symmetric cost is a bare
418
- // `git@fileserver:/srv/x.git` yielding a link to a page that does not exist.
419
- //
420
- // Distinct from `detectForge` above, and deliberately not folded into it: that
421
- // answers the strictly narrower "which forge CLI can answer a PR query", which
422
- // needs a recognized product AND an installed binary. This needs only a web
423
- // server. Two questions, two maps.
424
- export function remoteWebUrl(raw: string): string | null {
425
- const ref = parseRemoteRef(raw);
426
- if (!ref) return null;
427
-
428
- // [LAW:dataflow-not-control-flow] The scheme is the ONLY discriminator, and it
429
- // answers with VALUES — the web scheme and the web port — rather than gating
430
- // whether work happens. The port is where the two arms genuinely differ: an
431
- // http(s) port is part of the address a browser needs (a self-hosted forge on
432
- // :3000), while an ssh port says nothing about where the web UI listens, so
433
- // the ssh arm answers "" rather than carrying 2222 into an https URL.
434
- const web = ((): { scheme: string; port: string } | null => {
435
- if (ref.scheme === "https") return { scheme: "https", port: ref.port };
436
- if (ref.scheme === "http") return { scheme: "http", port: ref.port };
437
- if (ref.scheme === "ssh" || ref.scheme === "git")
438
- return { scheme: "https", port: "" };
439
- return null;
440
- })();
441
- if (!web || !ref.host) return null;
442
-
443
- const repoPath = ref.path.replace(/\.git$/, "");
444
- if (!repoPath) return null;
445
-
446
- const authority = web.port ? `${ref.host}:${web.port}` : ref.host;
447
- return `${web.scheme}://${authority}/${repoPath}`;
448
- }
449
-
450
- // The repo's browsable page: one projection of `repoRemoteUrl`, so it names the
451
- // same repository `repoName` does. A repo whose identifying remote is a bare
452
- // path or a file:// mirror — or which has no remotes — has no web home, and
453
- // null says exactly that rather than borrowing another remote's page.
454
- export function repoWebUrl(remotes: readonly GitRemote[]): string | null {
455
- const url = repoRemoteUrl(remotes);
456
- return url === null ? null : remoteWebUrl(url);
457
- }
458
-
459
- export function classifyForgePr(
460
- label: string,
461
- result: LaunchResult,
462
- noPrPattern: RegExp,
463
- parse: (stdout: string) => Outcome<PullRequest>,
464
- ): Outcome<PullRequest> {
465
- if (result.ok) return parse(result.stdout);
466
- // ENOENT (no forge CLI on PATH) is a static configuration absence, not a
467
- // transient lookup failure — it never showed a PR, so showing nothing costs
468
- // nothing. [LAW:no-silent-failure] Every OTHER spawn failure (EACCES,
469
- // resource limits) means the CLI is present but could not launch — a real
470
- // failure that must stay visible, so it falls through to the `failed` path.
471
- if (result.reason === "spawn-error" && /ENOENT/i.test(result.error ?? ""))
472
- return ABSENT;
473
- if (result.reason === "non-zero" && noPrPattern.test(result.stderr))
474
- return ABSENT;
475
- const detail = [
476
- result.reason,
477
- result.exitCode != null ? `exit ${result.exitCode}` : null,
478
- result.error ?? firstLine(result.stderr),
479
- ]
480
- .filter(Boolean)
481
- .join(", ");
482
- return failed(`${label}: ${detail}`);
483
- }
484
-
485
- function isRecord(v: unknown): v is Record<string, unknown> {
486
- return typeof v === "object" && v !== null && !Array.isArray(v);
487
- }
488
-
489
- // [LAW:decomposition] The core git snapshot as a SINGLE `git status
490
- // --porcelain=v2 --branch` yields it. That one invocation reports branch,
491
- // short SHA, upstream, ahead/behind, and the full worktree status — so the
492
- // prior fan-out (status -b + two rev-list + a branch-fallback + rev-parse HEAD
493
- // + rev-parse @{u}, up to six spawns) collapses to one. Fewer spawns per cache
494
- // miss is the whole point of brandon-daemon-perf-bb9.1; folding these also
495
- // makes ahead/behind SHARE FATE with status (same subprocess) instead of being
496
- // an independently-failable partial state ([LAW:types-are-the-program]).
497
- interface CoreStatus {
498
- branch: string;
499
- status: "clean" | "dirty" | "conflicts";
500
- workingTree: WorkingTree;
501
- // Each an Outcome so "no upstream / unborn HEAD" (absent) stays distinct from
502
- // a value — the same three-state contract every on-demand field carries.
503
- aheadBehind: Outcome<AheadBehind>;
504
- sha: Outcome<string>;
505
- upstream: Outcome<string>;
506
- }
507
-
508
- // [LAW:effects-at-boundaries] Pure text→data: the subprocess (the effect) lives
509
- // in getCoreAsync; this parses its stdout. Exported so the accept/reject shape
510
- // table is unit-testable without spawning git.
511
- //
512
- // Porcelain v2 header lines (`# branch.<field> <value>`) carry branch/oid/
513
- // upstream/ab; entry lines classify the worktree:
514
- // `1 XY …` ordinary change → XY[0]=index, XY[1]=worktree ('.' = unmodified)
515
- // `2 XY …` rename/copy → same XY columns
516
- // `u …` unmerged → a conflict
517
- // `? path` untracked
518
- // `! path` ignored (never requested here; skipped)
519
- // The `(initial)` oid (unborn HEAD) and `(detached)` head are git's sentinels
520
- // for "no commit yet" and "detached" — mapped to absent-sha and the "detached"
521
- // branch label respectively, matching the prior fallback-chain behavior.
522
- export function parseStatusV2(stdout: string): CoreStatus {
523
- let branch = "detached";
524
- let sha: Outcome<string> = ABSENT;
525
- let upstream: Outcome<string> = ABSENT;
526
- let aheadBehind: Outcome<AheadBehind> = ABSENT;
527
- let staged = 0;
528
- let unstaged = 0;
529
- let untracked = 0;
530
- let conflicts = 0;
531
-
532
- for (const line of stdout.split("\n")) {
533
- if (!line) continue;
534
-
535
- if (line.startsWith("# ")) {
536
- const rest = line.slice(2);
537
- if (rest.startsWith("branch.oid ")) {
538
- const v = rest.slice("branch.oid ".length).trim();
539
- // Fixed 7-char truncation is the display contract. `git rev-parse
540
- // --short` auto-lengthens on collision, but re-spawning it here to
541
- // recover that would undo this segment's whole point — one porcelain=v2
542
- // read instead of a fan-out. The sha is display-only (never a lookup
543
- // key), so a 7-char ambiguity in a >1M-object repo is cosmetic.
544
- sha = v === "(initial)" ? ABSENT : ok(v.slice(0, 7));
545
- } else if (rest.startsWith("branch.head ")) {
546
- const v = rest.slice("branch.head ".length).trim();
547
- branch = v === "(detached)" ? "detached" : v;
548
- } else if (rest.startsWith("branch.upstream ")) {
549
- upstream = ok(rest.slice("branch.upstream ".length).trim());
550
- } else if (rest.startsWith("branch.ab ")) {
551
- // Format is exactly "+<ahead> -<behind>"; a shape mismatch leaves
552
- // aheadBehind absent rather than fabricating a count.
553
- const m = rest
554
- .slice("branch.ab ".length)
555
- .trim()
556
- .match(/^\+(\d+)\s+-(\d+)$/);
557
- if (m) {
558
- aheadBehind = ok({
559
- ahead: parseInt(m[1]!, 10),
560
- behind: parseInt(m[2]!, 10),
561
- });
562
- }
563
- }
564
- continue;
565
- }
566
-
567
- const kind = line[0];
568
- if (kind === "1" || kind === "2") {
569
- const xy = line.slice(2, 4);
570
- if (xy[0] !== ".") staged++;
571
- if (xy[1] !== ".") unstaged++;
572
- } else if (kind === "u") {
573
- conflicts++;
574
- } else if (kind === "?") {
575
- untracked++;
576
- }
577
- }
578
-
579
- let status: "clean" | "dirty" | "conflicts" = "clean";
580
- if (conflicts > 0) status = "conflicts";
581
- else if (staged || unstaged || untracked) status = "dirty";
582
-
583
- return {
584
- branch,
585
- status,
586
- aheadBehind,
587
- sha,
588
- upstream,
589
- workingTree: { staged, unstaged, untracked, conflicts },
590
- };
591
- }
592
-
593
- // `gh pr view --json number,state,url` → one JSON object. Only an OPEN PR is a
594
- // value; a MERGED/CLOSED PR for the branch is the domain's `absent`.
595
- export function parseGithubPr(stdout: string): Outcome<PullRequest> {
596
- let json: unknown;
597
- try {
598
- json = JSON.parse(stdout);
599
- } catch (e) {
600
- return failed(
601
- `gh pr view: unparseable JSON (${e instanceof Error ? e.message : String(e)})`,
602
- );
603
- }
604
- if (!isRecord(json)) return failed("gh pr view: JSON is not an object");
605
- const { number, state, url } = json;
606
- if (
607
- typeof number !== "number" ||
608
- typeof state !== "string" ||
609
- typeof url !== "string"
610
- ) {
611
- return failed("gh pr view: missing number/state/url");
612
- }
613
- if (state.toUpperCase() !== "OPEN") return ABSENT;
614
- return ok({ number, state, url });
615
- }
616
-
617
- // `glab mr view --output json` → one JSON object (iid / state / web_url). State
618
- // "opened" is the open MR; anything else is `absent`. NOTE: verified against
619
- // glab's documented JSON shape, not runtime-exercised here (glab not installed
620
- // on the dev machine) — the github path is the runtime-verified one.
621
- export function parseGitlabMr(stdout: string): Outcome<PullRequest> {
622
- let json: unknown;
623
- try {
624
- json = JSON.parse(stdout);
625
- } catch (e) {
626
- return failed(
627
- `glab mr view: unparseable JSON (${e instanceof Error ? e.message : String(e)})`,
628
- );
629
- }
630
- if (!isRecord(json)) return failed("glab mr view: JSON is not an object");
631
- const iid = json.iid;
632
- const state = json.state;
633
- const url = json.web_url;
634
- if (
635
- typeof iid !== "number" ||
636
- typeof state !== "string" ||
637
- typeof url !== "string"
638
- ) {
639
- return failed("glab mr view: missing iid/state/web_url");
640
- }
641
- if (state.toLowerCase() !== "opened") return ABSENT;
642
- return ok({ number: iid, state, url });
643
- }
644
-
645
- export class GitService {
646
- private isGitRepo(workingDir: string): boolean {
647
- try {
648
- return fs.existsSync(path.join(workingDir, ".git"));
649
- } catch {
650
- return false;
651
- }
652
- }
653
-
654
- // [LAW:types-are-the-program] args is a string[] so the boundary type
655
- // forbids the only-space-free-arguments contract the prior whitespace-split
656
- // implementation relied on. Returns the full LaunchResult — the typed
657
- // termination cause `launch` already computed — so `classify` can map it to
658
- // an Outcome without a thrown Error flattening that information away.
659
- private async execGitAsync(
660
- args: readonly string[],
661
- options: { cwd: string; timeout: number },
662
- ): Promise<LaunchResult> {
663
- return launch({
664
- bin: "git",
665
- args: [...args],
666
- cwd: options.cwd,
667
- env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" },
668
- timeoutMs: options.timeout,
669
- category: "git",
670
- });
671
- }
672
-
673
- // [LAW:locality-or-seam] Public so the daemon's GitDataProvider can key its
674
- // cache + watcher on the *effective* git directory — the same directory the
675
- // shell-runner will run git commands in. Caching on `findGitRoot(workingDir)`
676
- // alone is wrong when `projectDir` is set and is itself a git repo: the
677
- // shell-runner picks `projectDir` (see `computeGitInfo`'s gitDir resolution
678
- // below), but a workingDir-keyed cache would store that data under a
679
- // different key and wire invalidation to the wrong watcher. The contract:
680
- // `resolveEffectiveGitDir(workingDir, projectDir)` returns exactly the
681
- // directory `computeGitInfo` will use as `gitDir`. Both surfaces must agree.
682
- async resolveEffectiveGitDir(
683
- workingDir: string,
684
- projectDir?: string,
685
- ): Promise<Outcome<string>> {
686
- if (this.isWorktree(workingDir)) return ok(workingDir);
687
- if (projectDir && this.isGitRepo(projectDir)) return ok(projectDir);
688
- if (this.isGitRepo(workingDir)) return ok(workingDir);
689
- return this.findGitRoot(workingDir);
690
- }
691
-
692
- // [LAW:locality-or-seam] public so daemon-side caches can key on the
693
- // repoRoot they'd otherwise have to re-derive. `absent` is rev-parse's
694
- // non-zero exit — "not in a git repository", the everyday domain answer.
695
- async findGitRoot(workingDir: string): Promise<Outcome<string>> {
696
- return nonEmpty(
697
- classify(
698
- "git rev-parse --show-toplevel",
699
- await this.execGitAsync(["rev-parse", "--show-toplevel"], {
700
- cwd: workingDir,
701
- timeout: 2000,
702
- }),
703
- "absent",
704
- ),
705
- );
706
- }
707
-
708
- // [LAW:one-source-of-truth] No inner cache here. The daemon-side
709
- // GitDataProvider (src/daemon/cache/git.ts) is the single cache. Layering
710
- // a per-process cache on top of an already-cached call would double the
711
- // invalidation surface — exactly the trap that kz8.3 collapses.
712
- //
713
- // [LAW:no-silent-failure] Never rejects: helpers return outcomes by
714
- // construction, and an unexpected throw (a bug) is surfaced as a `failed`
715
- // outcome whose reason reaches the consuming boundary's log — not a blank
716
- // bar, not a swallowed branch.
717
- async getGitInfo(
718
- workingDir: string,
719
- options: GitInfoOptions = {},
720
- projectDir?: string,
721
- ): Promise<Outcome<GitInfo>> {
722
- try {
723
- return await this.computeGitInfo(workingDir, options, projectDir);
724
- } catch (e) {
725
- return failed(`git: ${e instanceof Error ? e.message : String(e)}`);
726
- }
727
- }
728
-
729
- private async computeGitInfo(
730
- workingDir: string,
731
- options: GitInfoOptions = {},
732
- projectDir?: string,
733
- ): Promise<Outcome<GitInfo>> {
734
- let gitDir: string;
735
- const isWorktreeDir = this.isWorktree(workingDir);
736
-
737
- if (isWorktreeDir) {
738
- // Worktree's .git is a file pointing to the main repo;
739
- // git commands must run from the worktree directory.
740
- gitDir = workingDir;
741
- } else if (projectDir && this.isGitRepo(projectDir)) {
742
- gitDir = projectDir;
743
- } else if (this.isGitRepo(workingDir)) {
744
- gitDir = workingDir;
745
- } else {
746
- const foundGitRoot = await this.findGitRoot(workingDir);
747
- if (foundGitRoot.kind !== "ok") return foundGitRoot;
748
- gitDir = foundGitRoot.value;
749
- }
750
-
751
- // branch/status/ahead-behind/sha/upstream are the core, and one
752
- // `git status --porcelain=v2 --branch` yields them all: without branch and
753
- // status there is no useful GitInfo, so a failed core fetch fails the whole
754
- // outcome rather than dressing up as a clean repo on a fallback branch.
755
- const core = await this.getCoreAsync(gitDir);
756
- if (core.kind !== "ok") return core;
757
-
758
- const result: GitInfo = {
759
- branch: core.value.branch,
760
- status: core.value.status,
761
- aheadBehind: core.value.aheadBehind,
762
- };
763
-
764
- // sha, upstream, and the worktree counts all rode in on the core call —
765
- // attaching them here is a memory read, not another spawn.
766
- if (options.showWorkingTree) result.workingTree = core.value.workingTree;
767
- if (options.showSha) result.sha = core.value.sha;
768
- if (options.showUpstream) result.upstream = core.value.upstream;
769
-
770
- // Heavy operations stay serial — each is an expensive git invocation and
771
- // running them one at a time bounds concurrent git load per fetch.
772
- if (options.showTag) {
773
- result.tag = await this.getNearestTagAsync(gitDir);
774
- }
775
- if (options.showTimeSinceCommit) {
776
- result.timeSinceCommit = await this.getTimeSinceLastCommitAsync(gitDir);
777
- }
778
-
779
- // Light operations run in parallel. Helpers never reject — failure is a
780
- // value in the outcome — so plain Promise.all replaces the allSettled +
781
- // untyped resultMap machinery the swallowing design required.
782
- // [LAW:one-source-of-truth] repoName and repoUrl are two projections of ONE
783
- // remotes read, so they can never disagree about origin and asking for both
784
- // costs exactly one spawn. A failed read fails both alike, by construction.
785
- const [stashCount, remotes] = await Promise.all([
786
- options.showStashCount ? this.getStashCountAsync(gitDir) : undefined,
787
- options.showRepoName || options.showRepoUrl
788
- ? this.getRemotesAsync(gitDir)
789
- : undefined,
790
- ]);
791
- if (stashCount !== undefined) result.stashCount = stashCount;
792
- if (remotes !== undefined && options.showRepoName) {
793
- result.repoName = derived(remotes, (r) => this.repoNameFrom(r, gitDir));
794
- result.isWorktree = isWorktreeDir;
795
- }
796
- if (remotes !== undefined && options.showRepoUrl) {
797
- result.repoUrl = derived(remotes, repoWebUrl);
798
- }
799
-
800
- if (options.showOperation) {
801
- result.operation = this.getOngoingOperation(gitDir);
802
- }
803
-
804
- return ok(result);
805
- }
806
-
807
- // [LAW:locality-or-seam] Public so the daemon-side provider can watch the
808
- // real HEAD/index files even for git worktrees. For a regular repo this is
809
- // `<workingDir>/.git`. For a worktree, `<workingDir>/.git` is a *file*
810
- // containing `gitdir: <abs-path-to-worktree-metadata-dir>` and the actual
811
- // HEAD/index live inside that metadata dir — watching `<workingDir>/.git/HEAD`
812
- // would fail (no such path) and the cache would never invalidate. Returning
813
- // the resolved gitDir lets the provider point watchers at real files.
814
- //
815
- // [LAW:no-defensive-null-guards] The try/catch is a trust-boundary guard,
816
- // not a silent skip — fs races (file removed between existsSync and
817
- // statSync), permission errors, or unreadable .git files fall back to the
818
- // dotGit path so callers always get a string, never a throw.
819
- resolveGitDir(workingDir: string): string {
820
- const dotGit = path.join(workingDir, ".git");
821
- try {
822
- if (fs.existsSync(dotGit) && fs.statSync(dotGit).isFile()) {
823
- const content = fs.readFileSync(dotGit, "utf-8");
824
- const match = content.match(/^gitdir:\s*(.+)$/m);
825
- if (match?.[1]) {
826
- return path.resolve(workingDir, match[1].trim());
827
- }
828
- }
829
- } catch {
830
- // Fall through to the dotGit fallback below.
831
- }
832
- return dotGit;
833
- }
834
-
835
- private getOngoingOperation(workingDir: string): Outcome<string> {
836
- try {
837
- const gitDir = this.resolveGitDir(workingDir);
838
-
839
- if (fs.existsSync(path.join(gitDir, "MERGE_HEAD"))) return ok("MERGE");
840
- if (fs.existsSync(path.join(gitDir, "CHERRY_PICK_HEAD")))
841
- return ok("CHERRY-PICK");
842
- if (fs.existsSync(path.join(gitDir, "REVERT_HEAD"))) return ok("REVERT");
843
- if (fs.existsSync(path.join(gitDir, "BISECT_LOG"))) return ok("BISECT");
844
- if (
845
- fs.existsSync(path.join(gitDir, "rebase-merge")) ||
846
- fs.existsSync(path.join(gitDir, "rebase-apply"))
847
- )
848
- return ok("REBASE");
849
-
850
- return ABSENT;
851
- } catch (e) {
852
- return failed(
853
- `git operation probe: ${e instanceof Error ? e.message : String(e)}`,
854
- );
855
- }
856
- }
857
-
858
- private async getNearestTagAsync(
859
- workingDir: string,
860
- ): Promise<Outcome<string>> {
861
- // non-zero = no tags reachable — describe's domain answer.
862
- return nonEmpty(
863
- classify(
864
- "git describe --tags",
865
- await this.execGitAsync(["describe", "--tags", "--abbrev=0"], {
866
- cwd: workingDir,
867
- timeout: 2000,
868
- }),
869
- "absent",
870
- ),
871
- );
872
- }
873
-
874
- private async getTimeSinceLastCommitAsync(
875
- workingDir: string,
876
- ): Promise<Outcome<number>> {
877
- // non-zero = no commits yet (empty repo) — a domain answer.
878
- const r = nonEmpty(
879
- classify(
880
- "git log -1",
881
- await this.execGitAsync(["log", "-1", "--format=%ct"], {
882
- cwd: workingDir,
883
- timeout: 2000,
884
- }),
885
- "absent",
886
- ),
887
- );
888
- if (r.kind !== "ok") return r;
889
-
890
- const commitTime = parseInt(r.value) * 1000;
891
- if (Number.isNaN(commitTime)) {
892
- return failed(`git log -1: unparseable timestamp "${r.value}"`);
893
- }
894
- const now = Date.now();
895
- return ok(Math.floor((now - commitTime) / 1000));
896
- }
897
-
898
- private async getStashCountAsync(
899
- workingDir: string,
900
- ): Promise<Outcome<number>> {
901
- // An empty stash list is a REAL count of 0; only a transport/exit failure
902
- // is `failed` — the meaning-erasure the old catch-to-0 created is
903
- // unrepresentable now. `stash list` never exits non-zero as an answer.
904
- const r = classify(
905
- "git stash list",
906
- await this.execGitAsync(["stash", "list"], {
907
- cwd: workingDir,
908
- timeout: 2000,
909
- }),
910
- "failed",
911
- );
912
- if (r.kind !== "ok") return r;
913
- const stashList = r.value.trim();
914
- return ok(stashList ? stashList.split("\n").length : 0);
915
- }
916
-
917
- // [LAW:one-source-of-truth] The one read of this repo's remotes per call site.
918
- // `repoName` and `repoUrl` share a single call from `computeGitInfo`, so those
919
- // two can never disagree about which remote is origin — that pair used to be
920
- // one `config --get remote.origin.url` each.
921
- //
922
- // The PR cache deliberately keeps its OWN call (`getRepoRemoteUrl`, from
923
- // src/daemon/cache/git.ts): the forge lookup is a network resource cached
924
- // under its own longer TTL, keyed `repoRoot|branch|remote`, and folding its
925
- // remote read into GitInfo would tie a network cache's input to the local
926
- // cache's fs-watched refresh cycle — the separation those two TTLs exist to
927
- // create. So this is one read per lifecycle, not one read overall.
928
- //
929
- // `--local` scopes the read to THIS repository's config. Without it the read
930
- // merges system → global → local, so a stray `remote.origin.url` in
931
- // ~/.gitconfig sorts FIRST and would hijack repoUrl, repoName and the PR cache
932
- // key for every repo on the machine — a regression against the `git config
933
- // --get` this replaced, whose last-wins was really scope precedence. Scoping
934
- // also makes config order unambiguous, so "first url" is a fact rather than a
935
- // bet about which scope won.
936
- //
937
- // `--get-regexp` exits 1 when NOTHING matches, which is a repo with no remotes
938
- // configured — a domain answer, so it lands as an EMPTY LIST rather than an
939
- // `absent` arm. [LAW:dataflow-not-control-flow] Every projection then reads
940
- // "no remotes" off the empty set (no repoUrl, basename repoName) instead of
941
- // carrying a second no-value state through three consumers. Exit 128 (an
942
- // unreadable or corrupt config) is NOT that answer and stays `failed`, so a
943
- // broken repo never renders as an empty one. [LAW:no-silent-failure]
944
- async getRemotesAsync(workingDir: string): Promise<Outcome<GitRemote[]>> {
945
- const r = classify(
946
- "git config --get-regexp remote url",
947
- await this.execGitAsync(
948
- ["config", "--local", "--get-regexp", "^remote\\..*\\.url$"],
949
- { cwd: workingDir, timeout: 2000 },
950
- ),
951
- 1,
952
- );
953
- if (r.kind === "failed") return r;
954
- return ok(r.kind === "ok" ? parseRemotes(r.value) : []);
955
- }
956
-
957
- // [LAW:effects-at-boundaries] Pure projection over remotes the caller already
958
- // read — no spawn of its own, so it cannot disagree with the sibling repoUrl
959
- // projection about what origin is.
960
- //
961
- // A local-only repo's name is its directory name BY POLICY (the display
962
- // contract for repos without a remote) — never as an error fallback; a failed
963
- // remotes read stays failed at the call site instead of borrowing this rule.
964
- private repoNameFrom(
965
- remotes: readonly GitRemote[],
966
- workingDir: string,
967
- ): string {
968
- const url = repoRemoteUrl(remotes);
969
- return (
970
- (url === null ? null : repoNameFromUrl(url)) ?? path.basename(workingDir)
971
- );
972
- }
973
-
974
- // [LAW:locality-or-seam] Public so the daemon's GitDataProvider can read the
975
- // remote to fold into its PR cache key (the PR value depends on the remote; a
976
- // re-pointed remote must be a new key). Raw, unparsed — the forge detector
977
- // reads the host from it. No remotes → `absent` (hence no forge PR concept),
978
- // distinct from a failed read.
979
- //
980
- // Named for the repo rather than for `origin` because it no longer reads
981
- // `origin` specifically — it projects `forgeRemoteUrl` over the same picked
982
- // remote repoName and repoUrl use, preferring a url a forge CLI recognizes.
983
- // That preference is the point: `detectForge` gates the whole PR lookup, so a
984
- // browsable-but-unrecognized mirror listed first would silently decide the
985
- // branch has no PR.
986
- async getRepoRemoteUrl(workingDir: string): Promise<Outcome<string>> {
987
- const remotes = await this.getRemotesAsync(workingDir);
988
- if (remotes.kind !== "ok") return remotes;
989
- const url = forgeRemoteUrl(remotes.value);
990
- return url === null ? ABSENT : ok(url);
991
- }
992
-
993
- // [LAW:single-enforcer] One boundary for forge-CLI spawns. Mirrors
994
- // execGitAsync but carries the "forge" launch category and a longer timeout
995
- // (this is a network call, not a local git read). Returns the typed
996
- // LaunchResult so classifyForgePr maps every termination cause to an Outcome.
997
- private async execForgeAsync(
998
- bin: string,
999
- args: readonly string[],
1000
- options: { cwd: string; timeout: number },
1001
- ): Promise<LaunchResult> {
1002
- return launch({
1003
- bin,
1004
- args: [...args],
1005
- cwd: options.cwd,
1006
- env: { ...process.env },
1007
- timeoutMs: options.timeout,
1008
- category: "forge",
1009
- });
1010
- }
1011
-
1012
- // [LAW:effects-at-boundaries] Resolve the branch's open PR/MR via the forge
1013
- // CLI. Pure dispatch over a remote the CALLER has already read: pick the
1014
- // forge by host, run its CLI, fold the launch result into an Outcome. The
1015
- // remote is a parameter (not read here) so the cache layer can fold it into
1016
- // its key in the same read — no caching here; the daemon's GitDataProvider
1017
- // owns the PR cache + TTL (a network resource wants a longer, independent
1018
- // lifecycle than local git state). `absent` when the host is no recognized
1019
- // forge; the CLI dispatch then classifies the rest.
1020
- async resolvePullRequest(
1021
- workingDir: string,
1022
- remoteUrl: string,
1023
- ): Promise<Outcome<PullRequest>> {
1024
- const forge = detectForge(remoteUrl);
1025
- if (forge === "github") {
1026
- return classifyForgePr(
1027
- "gh pr view",
1028
- await this.execForgeAsync(
1029
- "gh",
1030
- ["pr", "view", "--json", "number,state,url"],
1031
- { cwd: workingDir, timeout: 5000 },
1032
- ),
1033
- /no (open )?pull requests? found/i,
1034
- parseGithubPr,
1035
- );
1036
- }
1037
- if (forge === "gitlab") {
1038
- return classifyForgePr(
1039
- "glab mr view",
1040
- await this.execForgeAsync("glab", ["mr", "view", "--output", "json"], {
1041
- cwd: workingDir,
1042
- timeout: 5000,
1043
- }),
1044
- /no (open )?merge requests? (found|available)/i,
1045
- parseGitlabMr,
1046
- );
1047
- }
1048
- // Recognized neither host → no forge integration for this remote.
1049
- return ABSENT;
1050
- }
1051
-
1052
- private isWorktree(workingDir: string): boolean {
1053
- try {
1054
- const gitDir = path.join(workingDir, ".git");
1055
- if (fs.existsSync(gitDir) && fs.statSync(gitDir).isFile()) {
1056
- return true;
1057
- }
1058
- return false;
1059
- } catch {
1060
- return false;
1061
- }
1062
- }
1063
-
1064
- // [LAW:single-enforcer] The one core git read. `git status --porcelain=v2
1065
- // --branch` reports branch, short SHA, upstream, ahead/behind, and worktree
1066
- // status in a single subprocess — the fan-out this method replaces spawned up
1067
- // to six (status -b, two rev-list, a branch fallback, rev-parse HEAD, rev-parse
1068
- // @{u}). Parsing is delegated to the pure `parseStatusV2`.
1069
- private async getCoreAsync(workingDir: string): Promise<Outcome<CoreStatus>> {
1070
- debug(`[GIT-EXEC] Running git status --porcelain=v2 in ${workingDir}`);
1071
- const r = classify(
1072
- "git status --porcelain=v2 --branch",
1073
- await this.execGitAsync(["status", "--porcelain=v2", "--branch"], {
1074
- cwd: workingDir,
1075
- timeout: 2000,
1076
- }),
1077
- // `git status` has no non-zero domain answer; any failure means the
1078
- // core state is unknown — no fabricated "clean" on a fallback branch.
1079
- "failed",
1080
- );
1081
- if (r.kind !== "ok") return r;
1082
- return ok(parseStatusV2(r.value));
1083
- }
1084
- }