@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.
- package/dist/index.mjs +72 -71
- package/package.json +5 -6
- package/src/check.ts +0 -478
- package/src/cli-flags.ts +0 -8
- package/src/click/wire.ts +0 -158
- package/src/config/action.ts +0 -329
- package/src/config/cli.ts +0 -71
- package/src/config/default-dsl-config.ts +0 -1645
- package/src/config/disclosure.ts +0 -170
- package/src/config/dsl-loader.ts +0 -339
- package/src/config/dsl-types.ts +0 -581
- package/src/config/edit-chrome.ts +0 -559
- package/src/config/help.ts +0 -151
- package/src/config/ident.ts +0 -22
- package/src/config/layout-ops.ts +0 -177
- package/src/config/loader/actions.ts +0 -972
- package/src/config/loader/cache.ts +0 -206
- package/src/config/loader/cross-ref.ts +0 -714
- package/src/config/loader/cycles.ts +0 -148
- package/src/config/loader/diagnostics.ts +0 -99
- package/src/config/loader/discovery.ts +0 -182
- package/src/config/loader/edit-mode.ts +0 -137
- package/src/config/loader/emit-schema.ts +0 -68
- package/src/config/loader/globals.ts +0 -269
- package/src/config/loader/helpers.ts +0 -48
- package/src/config/loader/layout.ts +0 -693
- package/src/config/loader/looks.ts +0 -96
- package/src/config/loader/menu-synth.ts +0 -435
- package/src/config/loader/merge.ts +0 -115
- package/src/config/loader/persist-target.ts +0 -67
- package/src/config/loader/presets.ts +0 -119
- package/src/config/loader/refs.ts +0 -100
- package/src/config/loader/reserved-namespace.ts +0 -38
- package/src/config/loader/segments.ts +0 -120
- package/src/config/loader/validate-core.ts +0 -737
- package/src/config/loader/variables.ts +0 -260
- package/src/config/menu-keys.ts +0 -139
- package/src/config/option-domain.ts +0 -164
- package/src/config/presets.ts +0 -326
- package/src/config/settings-menu.ts +0 -775
- package/src/daemon/acquire.ts +0 -684
- package/src/daemon/cache/git.ts +0 -649
- package/src/daemon/cache/render.ts +0 -623
- package/src/daemon/cache/session-usage-store.ts +0 -720
- package/src/daemon/cache/watchers.ts +0 -249
- package/src/daemon/client-debug.ts +0 -120
- package/src/daemon/client-stats.ts +0 -130
- package/src/daemon/client-transport.ts +0 -273
- package/src/daemon/client.ts +0 -78
- package/src/daemon/config-overrides-store.ts +0 -663
- package/src/daemon/debug-types.ts +0 -91
- package/src/daemon/debug.ts +0 -264
- package/src/daemon/fork-bomb-breaker.ts +0 -351
- package/src/daemon/limits.ts +0 -211
- package/src/daemon/log.ts +0 -81
- package/src/daemon/parent-watchdog.ts +0 -87
- package/src/daemon/paths.ts +0 -211
- package/src/daemon/process-fingerprint.ts +0 -146
- package/src/daemon/protocol.ts +0 -292
- package/src/daemon/render-payload.ts +0 -1256
- package/src/daemon/server.ts +0 -1330
- package/src/daemon/session-state-file.ts +0 -108
- package/src/daemon/session-state.ts +0 -237
- package/src/daemon/socket-lease.ts +0 -209
- package/src/daemon/socket-ownership.ts +0 -209
- package/src/daemon/stats.ts +0 -235
- package/src/daemon/verbs/config-validators.ts +0 -250
- package/src/daemon/verbs/index.ts +0 -706
- package/src/daemon/verbs/state-validators.ts +0 -249
- package/src/daemon/verbs/validator-registry.ts +0 -457
- package/src/demo/dsl.ts +0 -143
- package/src/demo/mock-data.ts +0 -67
- package/src/demo/statusline.json5 +0 -94
- package/src/dsl/node-registry.ts +0 -374
- package/src/dsl/render.ts +0 -803
- package/src/help-text.ts +0 -90
- package/src/index.ts +0 -210
- package/src/install/currency.ts +0 -197
- package/src/install/index.ts +0 -557
- package/src/proc/launch.ts +0 -459
- package/src/proc/stats-handle.ts +0 -13
- package/src/render/action.ts +0 -883
- package/src/render/active-segment.ts +0 -78
- package/src/render/diagnostic-style.ts +0 -23
- package/src/render/diagnostic-text.ts +0 -77
- package/src/render/error-glyph.ts +0 -53
- package/src/render/menu.ts +0 -257
- package/src/render/outcome-plan.ts +0 -45
- package/src/render/picker.ts +0 -372
- package/src/render/segment-color.ts +0 -74
- package/src/render/split-lines.ts +0 -51
- package/src/render/strip.ts +0 -228
- package/src/segments/cache.ts +0 -131
- package/src/segments/context.ts +0 -190
- package/src/segments/git.ts +0 -1084
- package/src/segments/metrics.ts +0 -187
- package/src/segments/pricing.ts +0 -452
- package/src/segments/session.ts +0 -23
- package/src/segments/tmux.ts +0 -74
- package/src/template-engine/cells.ts +0 -90
- package/src/template-engine/colors.ts +0 -124
- package/src/template-engine/engine.ts +0 -108
- package/src/template-engine/funcs.ts +0 -232
- package/src/template-engine/index.ts +0 -11
- package/src/template-engine/layout.ts +0 -133
- package/src/template-engine/scope.ts +0 -62
- package/src/template-engine/sparkline.ts +0 -79
- package/src/themes/index.ts +0 -20
- package/src/themes/palette-resolvers.ts +0 -84
- package/src/themes/policy.ts +0 -393
- package/src/utils/cache.ts +0 -206
- package/src/utils/claude.ts +0 -683
- package/src/utils/color-support.ts +0 -118
- package/src/utils/formatters.ts +0 -99
- package/src/utils/logger.ts +0 -5
- package/src/utils/outcome.ts +0 -33
- package/src/utils/schema-validator.ts +0 -126
- package/src/utils/single-flight.ts +0 -57
- package/src/utils/terminal-width.ts +0 -51
- package/src/utils/terminal.ts +0 -11
- package/src/utils/transcript-fs.ts +0 -279
- package/src/var-system/index.ts +0 -24
- package/src/var-system/sources.ts +0 -1047
- package/src/var-system/store.ts +0 -223
- package/src/var-system/types.ts +0 -57
- package/src/version.ts +0 -17
package/src/segments/git.ts
DELETED
|
@@ -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
|
-
}
|