@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.
- package/dist/index.mjs +86 -85
- package/package.json +6 -6
- package/schema/cc-candybar.schema.json +193 -4
- package/src/check.ts +49 -27
- package/src/click/wire.ts +16 -0
- package/src/config/action.ts +57 -22
- package/src/config/default-dsl-config.ts +424 -55
- package/src/config/dsl-loader.ts +14 -2
- package/src/config/dsl-types.ts +59 -0
- package/src/config/loader/actions.ts +283 -109
- package/src/config/loader/cross-ref.ts +148 -28
- package/src/config/loader/emit-schema.ts +2 -0
- package/src/config/loader/globals.ts +118 -31
- package/src/config/loader/merge.ts +58 -1
- package/src/config/loader/persist-target.ts +32 -0
- package/src/config/loader/presets.ts +107 -0
- package/src/config/option-domain.ts +164 -0
- package/src/config/presets.ts +156 -0
- package/src/daemon/cache/git.ts +1 -1
- package/src/daemon/cache/render.ts +61 -7
- package/src/daemon/config-overrides-store.ts +322 -0
- package/src/daemon/paths.ts +10 -0
- package/src/daemon/render-payload.ts +84 -19
- package/src/daemon/server.ts +68 -55
- package/src/daemon/verbs/config-validators.ts +127 -0
- package/src/daemon/verbs/index.ts +129 -2
- package/src/daemon/verbs/state-validators.ts +98 -586
- package/src/daemon/verbs/validator-registry.ts +457 -0
- package/src/demo/dsl.ts +17 -10
- package/src/dsl/node-registry.ts +54 -39
- package/src/dsl/render.ts +158 -46
- package/src/help-text.ts +3 -3
- package/src/install/index.ts +2 -2
- package/src/render/action.ts +155 -33
- package/src/render/active-segment.ts +78 -0
- package/src/render/menu.ts +16 -11
- package/src/render/picker.ts +51 -13
- package/src/render/segment-color.ts +74 -0
- package/src/segments/git.ts +389 -48
- package/src/template-engine/colors.ts +67 -45
- package/src/template-engine/engine.ts +11 -12
- package/src/themes/index.ts +1 -4
- package/src/themes/palette-resolvers.ts +22 -30
- package/src/themes/policy.ts +37 -16
package/src/segments/git.ts
CHANGED
|
@@ -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
|
-
|
|
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]
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
787
|
+
options.showRepoName || options.showRepoUrl
|
|
788
|
+
? this.getRemotesAsync(gitDir)
|
|
789
|
+
: undefined,
|
|
487
790
|
]);
|
|
488
791
|
if (stashCount !== undefined) result.stashCount = stashCount;
|
|
489
|
-
if (
|
|
490
|
-
result.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
|
-
|
|
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
|
|
614
|
-
await this.execGitAsync(
|
|
615
|
-
|
|
616
|
-
timeout: 2000,
|
|
617
|
-
|
|
618
|
-
|
|
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
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
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
|
-
//
|
|
637
|
-
//
|
|
638
|
-
//
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
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
|