@promptctl/cc-candybar 1.26.0 → 1.27.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 (44) hide show
  1. package/dist/index.mjs +86 -85
  2. package/package.json +6 -6
  3. package/schema/cc-candybar.schema.json +193 -4
  4. package/src/check.ts +49 -27
  5. package/src/click/wire.ts +16 -0
  6. package/src/config/action.ts +57 -22
  7. package/src/config/default-dsl-config.ts +424 -55
  8. package/src/config/dsl-loader.ts +14 -2
  9. package/src/config/dsl-types.ts +59 -0
  10. package/src/config/loader/actions.ts +283 -109
  11. package/src/config/loader/cross-ref.ts +148 -28
  12. package/src/config/loader/emit-schema.ts +2 -0
  13. package/src/config/loader/globals.ts +118 -31
  14. package/src/config/loader/merge.ts +58 -1
  15. package/src/config/loader/persist-target.ts +32 -0
  16. package/src/config/loader/presets.ts +107 -0
  17. package/src/config/option-domain.ts +164 -0
  18. package/src/config/presets.ts +156 -0
  19. package/src/daemon/cache/git.ts +1 -1
  20. package/src/daemon/cache/render.ts +61 -7
  21. package/src/daemon/config-overrides-store.ts +322 -0
  22. package/src/daemon/paths.ts +10 -0
  23. package/src/daemon/render-payload.ts +84 -19
  24. package/src/daemon/server.ts +68 -55
  25. package/src/daemon/verbs/config-validators.ts +127 -0
  26. package/src/daemon/verbs/index.ts +129 -2
  27. package/src/daemon/verbs/state-validators.ts +98 -586
  28. package/src/daemon/verbs/validator-registry.ts +457 -0
  29. package/src/demo/dsl.ts +17 -10
  30. package/src/dsl/node-registry.ts +54 -39
  31. package/src/dsl/render.ts +158 -46
  32. package/src/help-text.ts +3 -3
  33. package/src/install/index.ts +2 -2
  34. package/src/render/action.ts +155 -33
  35. package/src/render/active-segment.ts +78 -0
  36. package/src/render/menu.ts +16 -11
  37. package/src/render/picker.ts +51 -13
  38. package/src/render/segment-color.ts +74 -0
  39. package/src/segments/git.ts +389 -48
  40. package/src/template-engine/colors.ts +67 -45
  41. package/src/template-engine/engine.ts +11 -12
  42. package/src/themes/index.ts +1 -4
  43. package/src/themes/palette-resolvers.ts +22 -30
  44. package/src/themes/policy.ts +37 -16
@@ -47,6 +47,10 @@ export interface GitInfo {
47
47
  stashCount?: Outcome<number>;
48
48
  upstream?: Outcome<string>;
49
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>;
50
54
  isWorktree?: boolean;
51
55
  // [LAW:no-silent-failure] The forge lookup's three outcomes are all kept
52
56
  // distinct here: `ok` is an open PR, `absent` is "this branch has none / no
@@ -71,6 +75,9 @@ export interface GitInfoOptions {
71
75
  showStashCount?: boolean;
72
76
  showUpstream?: boolean;
73
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;
74
81
  // Opts into the forge (gh/glab) PR/MR lookup — a network call, so it is the
75
82
  // one option whose fetch the daemon caches on a longer, independent TTL than
76
83
  // the rest of GitInfo (see src/daemon/cache/git.ts). Never resolved by the
@@ -87,10 +94,21 @@ export interface GitInfoOptions {
87
94
  function classify(
88
95
  label: string,
89
96
  result: LaunchResult,
90
- nonZero: "absent" | "failed",
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,
91
107
  ): Outcome<string> {
92
108
  if (result.ok) return ok(result.stdout);
93
109
  if (result.reason === "non-zero" && nonZero === "absent") return ABSENT;
110
+ if (result.reason === "non-zero" && nonZero === result.exitCode)
111
+ return ABSENT;
94
112
  const detail = [
95
113
  result.reason,
96
114
  result.exitCode != null ? `exit ${result.exitCode}` : null,
@@ -105,6 +123,20 @@ function firstLine(s: string): string {
105
123
  return s.trim().split("\n", 1)[0] ?? "";
106
124
  }
107
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
+
108
140
  // Trim an ok stdout; an empty answer is the domain's "there is none".
109
141
  function nonEmpty(o: Outcome<string>): Outcome<string> {
110
142
  if (o.kind !== "ok") return o;
@@ -130,18 +162,101 @@ function nonEmpty(o: Outcome<string>): Outcome<string> {
130
162
  // timeout / signal / rate-limited → failed (forge couldn't answer)
131
163
  export type ForgeName = "github" | "gitlab";
132
164
 
133
- // [LAW:types-are-the-program] Extract the host from a git remote, handling the
134
- // two shapes git uses: scp-like `[user@]host:path` and URL `scheme://[user@]
135
- // host[:port]/path`. The URL form is checked first its `host` in a scp regex
136
- // would mis-capture the scheme (`https` before `://`). Returns null for an
137
- // unrecognized shape (local path, unknown syntax).
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.
138
258
  function remoteHost(remoteUrl: string): string | null {
139
- const url = remoteUrl.trim();
140
- const proto = url.match(/^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]+@)?([^/:]+)/i);
141
- if (proto) return proto[1]!.toLowerCase();
142
- const scp = url.match(/^(?:[^@/]+@)?([^/:]+):/);
143
- if (scp) return scp[1]!.toLowerCase();
144
- return null;
259
+ return parseRemoteRef(remoteUrl)?.host || null;
145
260
  }
146
261
 
147
262
  // [LAW:types-are-the-program] Branch on the HOST, not a substring of the whole
@@ -158,6 +273,189 @@ export function detectForge(remoteUrl: string): ForgeName | null {
158
273
  return null;
159
274
  }
160
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
+
161
459
  export function classifyForgePr(
162
460
  label: string,
163
461
  result: LaunchResult,
@@ -481,15 +779,23 @@ export class GitService {
481
779
  // Light operations run in parallel. Helpers never reject — failure is a
482
780
  // value in the outcome — so plain Promise.all replaces the allSettled +
483
781
  // untyped resultMap machinery the swallowing design required.
484
- const [stashCount, repoName] = await Promise.all([
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([
485
786
  options.showStashCount ? this.getStashCountAsync(gitDir) : undefined,
486
- options.showRepoName ? this.getRepoNameAsync(gitDir) : undefined,
787
+ options.showRepoName || options.showRepoUrl
788
+ ? this.getRemotesAsync(gitDir)
789
+ : undefined,
487
790
  ]);
488
791
  if (stashCount !== undefined) result.stashCount = stashCount;
489
- if (repoName !== undefined) {
490
- result.repoName = repoName;
792
+ if (remotes !== undefined && options.showRepoName) {
793
+ result.repoName = derived(remotes, (r) => this.repoNameFrom(r, gitDir));
491
794
  result.isWorktree = isWorktreeDir;
492
795
  }
796
+ if (remotes !== undefined && options.showRepoUrl) {
797
+ result.repoUrl = derived(remotes, repoWebUrl);
798
+ }
493
799
 
494
800
  if (options.showOperation) {
495
801
  result.operation = this.getOngoingOperation(gitDir);
@@ -608,45 +914,80 @@ export class GitService {
608
914
  return ok(stashList ? stashList.split("\n").length : 0);
609
915
  }
610
916
 
611
- private async getRepoNameAsync(workingDir: string): Promise<Outcome<string>> {
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[]>> {
612
945
  const r = classify(
613
- "git config remote.origin.url",
614
- await this.execGitAsync(["config", "--get", "remote.origin.url"], {
615
- cwd: workingDir,
616
- timeout: 2000,
617
- }),
618
- // `config --get` exits 1 when the key is unset — "no remote", a domain
619
- // answer, not a failure.
620
- "absent",
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,
621
952
  );
622
953
  if (r.kind === "failed") return r;
954
+ return ok(r.kind === "ok" ? parseRemotes(r.value) : []);
955
+ }
623
956
 
624
- // A local-only repo's name is its directory name BY POLICY (the display
625
- // contract for repos without a remote) never as an error fallback; a
626
- // failed `git config` above stays failed instead of borrowing this rule.
627
- const remoteUrl = r.kind === "ok" ? r.value.trim() : "";
628
- if (!remoteUrl) return ok(path.basename(workingDir));
629
-
630
- const match = remoteUrl.match(/\/([^/]+?)(\.git)?$/);
631
- return ok(match?.[1] || path.basename(workingDir));
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
+ );
632
972
  }
633
973
 
634
974
  // [LAW:locality-or-seam] Public so the daemon's GitDataProvider can read the
635
- // remote to fold into its PR cache key (the PR value depends on the remote;
636
- // a re-pointed origin must be a new key). Raw origin URL (unparsed) — the
637
- // forge detector reads the host from it. `config --get` exits 1 when unset
638
- // `absent` (no remote, hence no forge PR concept), distinct from a failure.
639
- async getRemoteOriginUrl(workingDir: string): Promise<Outcome<string>> {
640
- return nonEmpty(
641
- classify(
642
- "git config remote.origin.url",
643
- await this.execGitAsync(["config", "--get", "remote.origin.url"], {
644
- cwd: workingDir,
645
- timeout: 2000,
646
- }),
647
- "absent",
648
- ),
649
- );
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);
650
991
  }
651
992
 
652
993
  // [LAW:single-enforcer] One boundary for forge-CLI spawns. Mirrors