@vzn/vx 0.0.646 → 0.0.649
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/README.md +29 -22
- package/launcher.cjs +9 -2
- package/launcher.sh +2 -2
- package/package.json +5 -5
- package/schemas/cache.json +5 -5
- package/schemas/error.json +1 -1
- package/schemas/info.json +16 -2
- package/schemas/last.json +112 -5
- package/schemas/plan.json +28 -2
- package/schemas/show.json +10 -1
- package/skills/vx/SKILL.md +18 -2
- package/src/bin.ts +53 -12
- package/src/cache/archive.ts +324 -23
- package/src/cache/cache.ts +268 -53
- package/src/cache/git-inputs.ts +219 -87
- package/src/cache/index.ts +3 -2
- package/src/cache/inputs.ts +460 -78
- package/src/cache/key-fold.ts +15 -6
- package/src/cache/layer.ts +49 -9
- package/src/cache/layered-cache.ts +123 -58
- package/src/cache/linked-inputs.ts +1 -1
- package/src/cache/output-index.ts +19 -3
- package/src/cache/plugin-cache.ts +49 -23
- package/src/cache/remote-hits.ts +72 -0
- package/src/cache/remote-lane.ts +90 -14
- package/src/cache/run-history.ts +70 -23
- package/src/cache/run-store.ts +11 -10
- package/src/cache/schema.ts +25 -6
- package/src/cache/tar-stream.ts +5 -0
- package/src/cache/umask.ts +1 -2
- package/src/cli/cache.ts +58 -27
- package/src/cli/completions.ts +12 -5
- package/src/cli/docs.ts +42 -16
- package/src/cli/help.ts +58 -19
- package/src/cli/index.ts +79 -33
- package/src/cli/info.ts +12 -6
- package/src/cli/init.ts +186 -31
- package/src/cli/last.ts +439 -45
- package/src/cli/lock.ts +197 -52
- package/src/cli/plan-format.ts +91 -49
- package/src/cli/plugin-commands.ts +70 -14
- package/src/cli/plugin-templates.ts +13 -13
- package/src/cli/registry.ts +209 -0
- package/src/cli/run.ts +109 -34
- package/src/cli/select.ts +143 -46
- package/src/cli/show.ts +59 -16
- package/src/cli/task-verb.ts +16 -6
- package/src/cli/upgrade.ts +151 -49
- package/src/cli/watch-cycle.ts +10 -5
- package/src/cli/watch-filter.ts +71 -3
- package/src/cli/watch-fs.ts +22 -6
- package/src/cli/watch-judge.ts +48 -6
- package/src/cli/watch.ts +163 -42
- package/src/cli/why.ts +211 -73
- package/src/cli/workspace-config.ts +54 -15
- package/src/config.ts +13 -1
- package/src/exec/env.ts +7 -2
- package/src/exec/executor.ts +154 -10
- package/src/exec/index.ts +6 -2
- package/src/exec/kill-tree.ts +15 -0
- package/src/exec/local-executor.ts +1 -1
- package/src/exec/proc-sample.ts +3 -1
- package/src/exec/runner.ts +253 -54
- package/src/exec/sandbox-binds.ts +7 -3
- package/src/exec/sandbox-paths.ts +1 -1
- package/src/exec/sandbox-runtime.ts +211 -24
- package/src/exec/sandbox-violations.ts +52 -20
- package/src/graph/critical-path.ts +79 -0
- package/src/graph/default-build.ts +19 -0
- package/src/graph/definition.ts +38 -0
- package/src/graph/folds.ts +38 -5
- package/src/graph/index.ts +4 -1
- package/src/graph/priorities.ts +74 -8
- package/src/graph/scheduler.ts +270 -60
- package/src/graph/task-graph.ts +228 -35
- package/src/handoff.ts +97 -9
- package/src/history/failure-mode.ts +6 -3
- package/src/history/history.ts +167 -61
- package/src/history/index.ts +2 -0
- package/src/history/metrics.ts +90 -30
- package/src/index.ts +30 -13
- package/src/orchestrator/admission.ts +10 -6
- package/src/orchestrator/affected-tasks.ts +11 -6
- package/src/orchestrator/deferred-outputs.ts +2 -0
- package/src/orchestrator/doctor.ts +134 -42
- package/src/orchestrator/execute-args.ts +4 -1
- package/src/orchestrator/execute-task.ts +130 -38
- package/src/orchestrator/fingerprint-watch.ts +1 -1
- package/src/orchestrator/graph-memo.ts +140 -0
- package/src/orchestrator/graph-stage.ts +18 -1
- package/src/orchestrator/held-servers.ts +1 -1
- package/src/orchestrator/hit-restore.ts +217 -119
- package/src/orchestrator/index.ts +4 -3
- package/src/orchestrator/local-floor.ts +119 -25
- package/src/orchestrator/local-shortcircuit.ts +28 -4
- package/src/orchestrator/lockfile-claim.ts +96 -16
- package/src/orchestrator/miss-save.ts +42 -8
- package/src/orchestrator/options.ts +8 -6
- package/src/orchestrator/output-host.ts +43 -14
- package/src/orchestrator/output-log.ts +90 -10
- package/src/orchestrator/persistent-task.ts +84 -29
- package/src/orchestrator/persistent.ts +16 -4
- package/src/orchestrator/placement.ts +185 -17
- package/src/orchestrator/plan.ts +64 -7
- package/src/orchestrator/plugin-host.ts +214 -42
- package/src/orchestrator/plugin.ts +113 -37
- package/src/orchestrator/prepare.ts +168 -30
- package/src/orchestrator/projects.ts +65 -40
- package/src/orchestrator/remote-prefetch.ts +119 -25
- package/src/orchestrator/run-artifacts.ts +95 -32
- package/src/orchestrator/run-context.ts +39 -41
- package/src/orchestrator/run-failures.ts +65 -23
- package/src/orchestrator/run-id.ts +6 -3
- package/src/orchestrator/run-keys.ts +149 -13
- package/src/orchestrator/run-live.ts +294 -0
- package/src/orchestrator/run-lock.ts +13 -7
- package/src/orchestrator/run-records.ts +35 -29
- package/src/orchestrator/run-report.ts +3 -3
- package/src/orchestrator/run-steps.ts +58 -22
- package/src/orchestrator/run-stop.ts +22 -5
- package/src/orchestrator/run.ts +331 -88
- package/src/orchestrator/sandbox-fix.ts +7 -5
- package/src/orchestrator/sandbox-request.ts +70 -20
- package/src/orchestrator/shell-verdict.ts +5 -5
- package/src/orchestrator/signals.ts +26 -1
- package/src/orchestrator/stable-keys.ts +92 -8
- package/src/orchestrator/steps.ts +2 -2
- package/src/orchestrator/task-artifacts.ts +90 -26
- package/src/orchestrator/task-hash.ts +78 -13
- package/src/orchestrator/task-log-buffer.ts +72 -36
- package/src/orchestrator/task-process.ts +7 -1
- package/src/orchestrator/telemetry-host.ts +22 -2
- package/src/orchestrator/telemetry.ts +53 -21
- package/src/orchestrator/upstream.ts +15 -0
- package/src/report/events.ts +32 -13
- package/src/report/forecast.ts +31 -9
- package/src/report/framed-output.ts +141 -50
- package/src/report/index.ts +1 -0
- package/src/report/logger.ts +170 -179
- package/src/report/path-links.ts +12 -4
- package/src/report/plain-output.ts +13 -5
- package/src/report/status-line.ts +81 -53
- package/src/report/summary.ts +114 -32
- package/src/report/terminal-signals.ts +13 -2
- package/src/settings.ts +7 -6
- package/src/util/bun-version.ts +4 -1
- package/src/util/colors.ts +4 -2
- package/src/util/config-frame.ts +41 -4
- package/src/{graph → util}/dependency-spec.ts +22 -13
- package/src/util/edit-distance.ts +13 -1
- package/src/util/env-flag.ts +9 -0
- package/src/util/errors.ts +59 -3
- package/src/util/essential-env.ts +57 -0
- package/src/util/git-ignored.ts +1 -1
- package/src/util/hash.ts +35 -0
- package/src/util/hide-env.ts +45 -5
- package/src/util/index.ts +21 -5
- package/src/util/num.ts +8 -0
- package/src/util/paths.ts +91 -21
- package/src/util/report.ts +6 -1
- package/src/util/secret-mask.ts +62 -1
- package/src/util/secrets.ts +7 -2
- package/src/util/size.ts +11 -3
- package/src/util/tail.ts +6 -1
- package/src/util/timing.ts +13 -1
- package/src/util/ui.ts +61 -3
- package/src/util/which.ts +1 -1
- package/src/util/write-file.ts +67 -0
- package/src/workspace/affected.ts +710 -107
- package/src/workspace/bracket-literal.ts +84 -0
- package/src/workspace/canonical-dir.ts +37 -0
- package/src/workspace/config-bytecode.ts +17 -11
- package/src/workspace/config-eval.ts +29 -8
- package/src/workspace/config-imports.ts +199 -58
- package/src/workspace/config-schema.ts +242 -39
- package/src/workspace/filter.ts +37 -8
- package/src/workspace/index.ts +11 -1
- package/src/workspace/json-data.ts +3 -4
- package/src/workspace/lockfile.ts +57 -8
- package/src/workspace/migration.ts +69 -18
- package/src/workspace/package-graph.ts +5 -4
- package/src/workspace/project-loader.ts +226 -37
- package/src/workspace/repo-id.ts +1 -1
- package/src/workspace/workspace.ts +176 -33
|
@@ -12,6 +12,7 @@ export interface TerminalSignals {
|
|
|
12
12
|
export function terminalSignals(
|
|
13
13
|
env: Readonly<Record<string, string | undefined>>,
|
|
14
14
|
): TerminalSignals {
|
|
15
|
+
if (inMultiplexer(env)) return { progress: false, notify: false }
|
|
15
16
|
const program = env['TERM_PROGRAM']
|
|
16
17
|
if (program === 'ghostty') return { progress: true, notify: true }
|
|
17
18
|
// iTerm2 posts OSC 9 since 3.0 and draws OSC 9;4 since 3.6.
|
|
@@ -24,6 +25,17 @@ export function terminalSignals(
|
|
|
24
25
|
return { progress: false, notify: false }
|
|
25
26
|
}
|
|
26
27
|
|
|
28
|
+
/** Inside tmux or screen, which keep the outer terminal's variables (`WT_SESSION`, its `TERM_PROGRAM`). */
|
|
29
|
+
function inMultiplexer(env: Readonly<Record<string, string | undefined>>): boolean {
|
|
30
|
+
const program = env['TERM_PROGRAM']
|
|
31
|
+
return (
|
|
32
|
+
env['TMUX'] !== undefined ||
|
|
33
|
+
env['STY'] !== undefined ||
|
|
34
|
+
program === 'tmux' ||
|
|
35
|
+
program === 'screen'
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
27
39
|
function atLeast(version: string | undefined, major: number, minor: number): boolean {
|
|
28
40
|
const m = /^(\d+)\.(\d+)/.exec(version ?? '')
|
|
29
41
|
if (m === null) return false
|
|
@@ -50,9 +62,8 @@ export function notifyEscape(text: string): string {
|
|
|
50
62
|
* tmux and screen pass it through only when configured to.
|
|
51
63
|
*/
|
|
52
64
|
export function terminalLinks(env: Readonly<Record<string, string | undefined>>): boolean {
|
|
53
|
-
if (env
|
|
65
|
+
if (inMultiplexer(env)) return false
|
|
54
66
|
const program = env['TERM_PROGRAM']
|
|
55
|
-
if (program === 'tmux' || program === 'screen') return false
|
|
56
67
|
if (program === 'ghostty' || program === 'WezTerm' || program === 'vscode') return true
|
|
57
68
|
if (program === 'iTerm.app') return atLeast(env['TERM_PROGRAM_VERSION'], 3, 1)
|
|
58
69
|
if (env['WT_SESSION'] !== undefined || env['KITTY_WINDOW_ID'] !== undefined) return true
|
package/src/settings.ts
CHANGED
|
@@ -15,8 +15,7 @@ import {
|
|
|
15
15
|
export type SettingKind =
|
|
16
16
|
| { readonly type: 'int'; readonly min: number }
|
|
17
17
|
| { readonly type: 'concurrency' }
|
|
18
|
-
|
|
19
|
-
| { readonly type: 'duration'; readonly timer?: true; readonly zero?: true }
|
|
18
|
+
| { readonly type: 'duration'; readonly timer?: true }
|
|
20
19
|
// `off`: `false` is a value, 0 bytes (the thing the size bounds is off).
|
|
21
20
|
| { readonly type: 'size'; readonly off?: true }
|
|
22
21
|
| { readonly type: 'bool' }
|
|
@@ -48,7 +47,7 @@ export const SETTINGS: readonly Setting[] = [
|
|
|
48
47
|
path: 'baseBranch',
|
|
49
48
|
kind: { type: 'string' },
|
|
50
49
|
default: "origin/HEAD's branch, then main, then master",
|
|
51
|
-
doc: 'The default branch: a run on it has no cache scope and a bare `--affected` no base; elsewhere both use the merge base with `origin/<baseBranch>`.',
|
|
50
|
+
doc: 'The default branch: a run on it has no cache scope and a bare `--affected` no base; elsewhere both use the merge base with `origin/<baseBranch>`. Named alone: `main`, not `origin/main`.',
|
|
52
51
|
},
|
|
53
52
|
{
|
|
54
53
|
path: 'affectedBase',
|
|
@@ -323,7 +322,7 @@ export function parseSettingValue(kind: SettingKind, raw: unknown): unknown {
|
|
|
323
322
|
return parseConcurrency(typeof raw === 'number' ? String(raw) : raw)
|
|
324
323
|
case 'duration': {
|
|
325
324
|
const n = typeof raw === 'string' ? parseDuration(raw) : null
|
|
326
|
-
if (n === null ||
|
|
325
|
+
if (n === null || n === 0) return null
|
|
327
326
|
return kind.timer === true && n > MAX_TIMEOUT_MS ? null : n
|
|
328
327
|
}
|
|
329
328
|
case 'size':
|
|
@@ -359,10 +358,12 @@ export function settingRefusal(
|
|
|
359
358
|
: typeof raw === 'string' && /^\d+$/.test(raw)
|
|
360
359
|
? Number(raw)
|
|
361
360
|
: null
|
|
362
|
-
|
|
361
|
+
const bare = n !== null && Number.isSafeInteger(n) && n > 0
|
|
362
|
+
// A bare number past the ceiling is past it in either unit it could mean.
|
|
363
|
+
const ms = bare ? n : typeof raw === 'string' ? parseDuration(raw) : null
|
|
364
|
+
if (bare && !(kind.timer === true && n > MAX_TIMEOUT_MS)) {
|
|
363
365
|
return `${where} takes a duration with its unit, like '${n}ms' or '${Math.max(1, Math.round(n / 1000))}s' (got ${got})`
|
|
364
366
|
}
|
|
365
|
-
const ms = typeof raw === 'string' ? parseDuration(raw) : null
|
|
366
367
|
if (kind.timer === true && ms !== null && ms > MAX_TIMEOUT_MS) {
|
|
367
368
|
return (
|
|
368
369
|
`${where} of ${got} exceeds the maximum timer delay (${MAX_TIMEOUT_MS} ms, ~24.8 days): ` +
|
package/src/util/bun-version.ts
CHANGED
|
@@ -20,8 +20,11 @@
|
|
|
20
20
|
* refusing would be heavier than the measurement supports. What the
|
|
21
21
|
* measurement supports is that a green exit can hide a truncated answer, and
|
|
22
22
|
* that is what the warning says.
|
|
23
|
+
*
|
|
24
|
+
* 1.4.4: vx expands a brace range (`{1..3}`) as `Bun.Glob` does from 1.4.4
|
|
25
|
+
* on; an older `Bun.Glob` reads it as text, so a scan and a match disagree.
|
|
23
26
|
*/
|
|
24
|
-
export const MIN_BUN = [1, 4,
|
|
27
|
+
export const MIN_BUN = [1, 4, 4] as const
|
|
25
28
|
|
|
26
29
|
/** True when `version` is older than {@link MIN_BUN}. */
|
|
27
30
|
export function isUnsupportedBun(version: string): boolean {
|
package/src/util/colors.ts
CHANGED
|
@@ -96,7 +96,9 @@ export function paint(
|
|
|
96
96
|
colors: ColorSupport,
|
|
97
97
|
opts: PaintOptions = {},
|
|
98
98
|
): string {
|
|
99
|
-
|
|
99
|
+
// Empty text paints nothing: an escape pair around no text is bytes and no
|
|
100
|
+
// mark (a full bar's empty remainder).
|
|
101
|
+
if (!colors.enabled || text === '') return text
|
|
100
102
|
let prefix = ''
|
|
101
103
|
if (opts.bold) prefix += BOLD
|
|
102
104
|
if (opts.dim) prefix += DIM
|
|
@@ -107,7 +109,7 @@ export function paint(
|
|
|
107
109
|
|
|
108
110
|
/**
|
|
109
111
|
* Identity hues for the two halves of a `project#task` id, never a status
|
|
110
|
-
* colour: one name, one hue, in every run, list and row (
|
|
112
|
+
* colour: one name, one hue, in every run, list and row (2026-10-15:
|
|
111
113
|
* every `#build` matches every other `#build`).
|
|
112
114
|
*/
|
|
113
115
|
const IDENTITY_PALETTE = [
|
package/src/util/config-frame.ts
CHANGED
|
@@ -18,7 +18,7 @@ const HEAD = /^(\/[^\n]*?\/(?:vx\.config|\.vx\/workspace)\.[cm]?[jt]s)(?:: (task
|
|
|
18
18
|
*/
|
|
19
19
|
export function configErrorFrame(message: string, cwd: string): string | undefined {
|
|
20
20
|
const head = HEAD.exec(message)
|
|
21
|
-
if (head === null) return
|
|
21
|
+
if (head === null) return positionFrame(message, cwd)
|
|
22
22
|
const [, file, fieldPath, colon] = head as unknown as [string, string, string?, string?]
|
|
23
23
|
const rest = message.slice(head[0].length)
|
|
24
24
|
// The field the refusal is about, past the path that leads to it:
|
|
@@ -35,6 +35,37 @@ export function configErrorFrame(message: string, cwd: string): string | undefin
|
|
|
35
35
|
}
|
|
36
36
|
const at = locate(source, segments)
|
|
37
37
|
if (at === undefined) return undefined
|
|
38
|
+
return framed(file, source, at, message.slice(file.length), cwd)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// The loader's `Project config <file>:<line>:<col>: …`: a syntax error,
|
|
42
|
+
// placed by Bun, in the config itself.
|
|
43
|
+
const POSITION =
|
|
44
|
+
/^(?:Project|Workspace) config (\/[^\n]*?\/(?:vx\.config|\.vx\/workspace)\.[cm]?[jt]s):(\d+):(\d+)(: [^]*)$/
|
|
45
|
+
|
|
46
|
+
function positionFrame(message: string, cwd: string): string | undefined {
|
|
47
|
+
const pos = POSITION.exec(message)
|
|
48
|
+
if (pos === null) return undefined
|
|
49
|
+
const [, file, line, col, rest] = pos as unknown as [string, string, string, string, string]
|
|
50
|
+
let source: string
|
|
51
|
+
try {
|
|
52
|
+
source = readFileSync(file, 'utf8')
|
|
53
|
+
} catch {
|
|
54
|
+
return undefined
|
|
55
|
+
}
|
|
56
|
+
const at = { line: Number(line), col: Math.max(1, Number(col)) }
|
|
57
|
+
if (at.line < 1 || at.line > source.split('\n').length) return undefined
|
|
58
|
+
return framed(file, source, at, rest, cwd)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** `<file>:<line>:<col><rest>` relative to `cwd`, over the source lines up to `at`. */
|
|
62
|
+
function framed(
|
|
63
|
+
file: string,
|
|
64
|
+
source: string,
|
|
65
|
+
at: { line: number; col: number },
|
|
66
|
+
rest: string,
|
|
67
|
+
cwd: string,
|
|
68
|
+
): string {
|
|
38
69
|
const lines = source.split('\n')
|
|
39
70
|
// Relative to where the user stands, unless that means climbing out.
|
|
40
71
|
const rel = path.relative(cwd, file)
|
|
@@ -50,7 +81,7 @@ export function configErrorFrame(message: string, cwd: string): string | undefin
|
|
|
50
81
|
.replace(/\t/g, ' ')
|
|
51
82
|
.replace(/\S/g, ' ')
|
|
52
83
|
frame.push(` ${' '.repeat(width)} | ${lead}^`)
|
|
53
|
-
return `${shown}:${at.line}:${at.col}${
|
|
84
|
+
return `${shown}:${at.line}:${at.col}${rest}\n\n${frame.join('\n')}`
|
|
54
85
|
}
|
|
55
86
|
|
|
56
87
|
/**
|
|
@@ -106,14 +137,20 @@ export function configRefusal(message: string, cwd: string): ConfigRefusal | und
|
|
|
106
137
|
const cut = framed.indexOf('\n\n')
|
|
107
138
|
const line = framed.slice(0, cut)
|
|
108
139
|
const frame = framed.slice(cut + 2)
|
|
109
|
-
|
|
140
|
+
// A path may hold spaces (`packages/my app/`); the first `:line:col` ends it.
|
|
141
|
+
const at = /^([^\n]+?:\d+:\d+):? (.*)$/s.exec(line)
|
|
110
142
|
if (at === null) return { where: line, problem: '', frame }
|
|
111
|
-
|
|
143
|
+
// After ` — `: the field meant, or how vx spells another runner's field.
|
|
144
|
+
const said =
|
|
145
|
+
/^(.*?)(?: \(allowed: ([^)]*)\))?(?: — (?:did you mean ([^?]+)\?|vx spells it (.+)))?$/s.exec(
|
|
146
|
+
at[2]!,
|
|
147
|
+
)!
|
|
112
148
|
return {
|
|
113
149
|
where: at[1]!,
|
|
114
150
|
problem: said[1]!.replace(/^has /, ''),
|
|
115
151
|
...(said[2] !== undefined ? { allowed: said[2] } : {}),
|
|
116
152
|
...(said[3] !== undefined ? { fix: `use ${said[3]}` } : {}),
|
|
153
|
+
...(said[4] !== undefined ? { fix: `use ${said[4]}` } : {}),
|
|
117
154
|
frame,
|
|
118
155
|
}
|
|
119
156
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// Parser for the Turbo/Nx-style task-edge micro-syntax used in
|
|
2
2
|
// `dependsOn` and `inputs.tasks`. Returns a small discriminated
|
|
3
|
-
// union the graph builder + cache filter both consume.
|
|
3
|
+
// union the graph builder + cache filter both consume. In util, not
|
|
4
|
+
// graph: the schema parses `inputs.tasks` at load (`vx lock`), and the
|
|
5
|
+
// workspace module cannot import the graph.
|
|
4
6
|
|
|
5
7
|
export type DependencySpec =
|
|
6
8
|
| { kind: 'self'; task: string; negated: boolean }
|
|
@@ -9,9 +11,12 @@ export type DependencySpec =
|
|
|
9
11
|
| { kind: 'wildcardSelf'; negated: boolean }
|
|
10
12
|
| { kind: 'wildcardDeps'; negated: boolean }
|
|
11
13
|
|
|
14
|
+
/** The field an entry came from, which the error names. */
|
|
15
|
+
type SpecField = 'dependsOn' | 'inputs.tasks'
|
|
16
|
+
|
|
12
17
|
export class DependencySpecError extends Error {
|
|
13
|
-
constructor(raw: string, message: string) {
|
|
14
|
-
super(`invalid
|
|
18
|
+
constructor(field: SpecField, raw: string, message: string) {
|
|
19
|
+
super(`invalid ${field} "${raw}": ${message}`)
|
|
15
20
|
}
|
|
16
21
|
}
|
|
17
22
|
|
|
@@ -31,49 +36,53 @@ export function compileTaskPattern(pattern: string): RegExp {
|
|
|
31
36
|
return new RegExp(`^${escaped}$`)
|
|
32
37
|
}
|
|
33
38
|
|
|
39
|
+
// `*` is the matcher's only metacharacter, so a run of them (`**`) matches
|
|
40
|
+
// every name: the bare wildcard, not a pattern that names a namespace.
|
|
41
|
+
const ALL_STARS = /^\*+$/
|
|
42
|
+
|
|
34
43
|
/**
|
|
35
44
|
* Parse one `dependsOn` / `inputs.tasks` entry.
|
|
36
45
|
*
|
|
37
46
|
* `name` → self.name
|
|
38
47
|
* `^name` → every dep workspace's `name` task
|
|
39
48
|
* `pkg#name` → specific package's `name` task (cross-edge)
|
|
40
|
-
* `*` → all same-project upstream (filter-only)
|
|
41
|
-
* `^*` → all dep upstream (filter-only)
|
|
49
|
+
* `*` → all same-project upstream (filter-only; `**` too)
|
|
50
|
+
* `^*` → all dep upstream (filter-only; `^**` too)
|
|
42
51
|
* `!<form>` → negation of any of the above (filter-only)
|
|
43
52
|
*
|
|
44
53
|
* Wildcards and negation are accepted everywhere — callers (the graph
|
|
45
54
|
* builder vs the filter) decide which ones make semantic sense and
|
|
46
55
|
* surface their own validation errors. This keeps the parser pure.
|
|
47
56
|
*/
|
|
48
|
-
export function parseDependencySpec(raw: string): DependencySpec {
|
|
49
|
-
if (raw.length === 0) throw new DependencySpecError(raw, 'empty spec')
|
|
57
|
+
export function parseDependencySpec(raw: string, field: SpecField): DependencySpec {
|
|
58
|
+
if (raw.length === 0) throw new DependencySpecError(field, raw, 'empty spec')
|
|
50
59
|
|
|
51
60
|
let negated = false
|
|
52
61
|
let s = raw
|
|
53
62
|
if (s.startsWith('!')) {
|
|
54
63
|
negated = true
|
|
55
64
|
s = s.slice(1)
|
|
56
|
-
if (s.length === 0) throw new DependencySpecError(raw, 'negation with no body')
|
|
65
|
+
if (s.length === 0) throw new DependencySpecError(field, raw, 'negation with no body')
|
|
57
66
|
}
|
|
58
67
|
|
|
59
68
|
if (s.startsWith('^')) {
|
|
60
69
|
const rest = s.slice(1)
|
|
61
|
-
if (rest.length === 0) throw new DependencySpecError(raw, '"^" with no task name')
|
|
62
|
-
if (rest
|
|
70
|
+
if (rest.length === 0) throw new DependencySpecError(field, raw, '"^" with no task name')
|
|
71
|
+
if (ALL_STARS.test(rest)) return { kind: 'wildcardDeps', negated }
|
|
63
72
|
if (rest.includes('#')) {
|
|
64
|
-
throw new DependencySpecError(raw, '"^" cannot combine with "pkg#task" — pick one')
|
|
73
|
+
throw new DependencySpecError(field, raw, '"^" cannot combine with "pkg#task" — pick one')
|
|
65
74
|
}
|
|
66
75
|
return { kind: 'deps', task: rest, negated }
|
|
67
76
|
}
|
|
68
77
|
|
|
69
|
-
if (s
|
|
78
|
+
if (ALL_STARS.test(s)) return { kind: 'wildcardSelf', negated }
|
|
70
79
|
|
|
71
80
|
const hashIdx = s.indexOf('#')
|
|
72
81
|
if (hashIdx >= 0) {
|
|
73
82
|
const project = s.slice(0, hashIdx)
|
|
74
83
|
const task = s.slice(hashIdx + 1)
|
|
75
84
|
if (!project || !task) {
|
|
76
|
-
throw new DependencySpecError(raw, 'pkg#task requires a non-empty project AND task')
|
|
85
|
+
throw new DependencySpecError(field, raw, 'pkg#task requires a non-empty project AND task')
|
|
77
86
|
}
|
|
78
87
|
return { kind: 'cross', project, task, negated }
|
|
79
88
|
}
|
|
@@ -23,7 +23,8 @@ export function editDistance(a: string, b: string, cap = 3): number {
|
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
25
|
* The ONE near-miss rule every "did you mean" hint applies: the closest
|
|
26
|
-
* candidate within two edits of `name
|
|
26
|
+
* candidate within two edits of `name` (for a name of four letters or
|
|
27
|
+
* fewer, one or a swap of two), or undefined — an exact match is
|
|
27
28
|
* not a hint, and anything further would guess rather than help. Task
|
|
28
29
|
* names, `pkg#task` halves, project names, flags and verbs all go through
|
|
29
30
|
* here so a typo is hinted the same way wherever it is typed.
|
|
@@ -33,6 +34,9 @@ export function nearest(
|
|
|
33
34
|
candidates: Iterable<string>,
|
|
34
35
|
maxEdits = 2,
|
|
35
36
|
): string | undefined {
|
|
37
|
+
// Two edits rewrite half of a word of four letters or fewer (`--poll`
|
|
38
|
+
// read as `--all`, `vx test` as `last`), unless they swap two letters.
|
|
39
|
+
const short = maxEdits === 2 && name.replace(/^-+/, '').length <= 4
|
|
36
40
|
let best: string | undefined
|
|
37
41
|
let bestD = maxEdits + 1
|
|
38
42
|
for (const c of candidates) {
|
|
@@ -40,6 +44,10 @@ export function nearest(
|
|
|
40
44
|
// `--continue-on-error` was nine edits from `--concurrency`, capped to
|
|
41
45
|
// the three `vx run`'s same-prefix hint allows, and offered.
|
|
42
46
|
const d = editDistance(name, c, maxEdits + 1)
|
|
47
|
+
// Edits that rewrite the whole of both leave nothing in common:
|
|
48
|
+
// `--filter=x9` offered the root, `//`.
|
|
49
|
+
if (d >= Math.max(name.length, c.length)) continue
|
|
50
|
+
if (short && d === 2 && !sameLetters(name, c)) continue
|
|
43
51
|
if (d < bestD) {
|
|
44
52
|
bestD = d
|
|
45
53
|
best = c
|
|
@@ -48,6 +56,10 @@ export function nearest(
|
|
|
48
56
|
return best === name ? undefined : best
|
|
49
57
|
}
|
|
50
58
|
|
|
59
|
+
function sameLetters(a: string, b: string): boolean {
|
|
60
|
+
return a.length === b.length && a.split('').sort().join('') === b.split('').sort().join('')
|
|
61
|
+
}
|
|
62
|
+
|
|
51
63
|
/**
|
|
52
64
|
* Up to `limit` candidates worth offering for `name`: the nearest by edit
|
|
53
65
|
* distance first, then any that contain it (or that it contains), case-
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a flag-like variable (`CI`, `GITHUB_ACTIONS`) is on: set, and not
|
|
3
|
+
* `''`, `0` or `false` in any case. Several vendors set `CI=true`, and a
|
|
4
|
+
* PowerShell `$false` writes `False`; each reader of CI asks this one, so a
|
|
5
|
+
* run cannot be CI to one part of vx and a laptop to another.
|
|
6
|
+
*/
|
|
7
|
+
export function envFlagOn(v: string | undefined): boolean {
|
|
8
|
+
return v !== undefined && v !== '' && v !== '0' && v.toLowerCase() !== 'false'
|
|
9
|
+
}
|
package/src/util/errors.ts
CHANGED
|
@@ -57,8 +57,12 @@ export function refusal(
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
// A refusal that opens with the config that caused it: the schema's
|
|
60
|
-
// `<file>: tasks.<name>
|
|
61
|
-
|
|
60
|
+
// `<file>: tasks.<name> …`, the loader's `<file> has unknown field …`, and
|
|
61
|
+
// its `Project config <file>: …` and `Workspace config at <file> …` (a load
|
|
62
|
+
// that failed: a syntax error, a missing import, no default object), which
|
|
63
|
+
// answered VX_E_REFUSED.
|
|
64
|
+
const CONFIG_REFUSAL =
|
|
65
|
+
/^(?:(?:Project|Workspace) config (?:at )?)?\/\S*\/(?:vx\.config|\.vx\/workspace)\.[cm]?[jt]s\b/
|
|
62
66
|
|
|
63
67
|
/**
|
|
64
68
|
* The stable code of anything `bin.ts` catches. A UserError's own, but
|
|
@@ -106,6 +110,30 @@ export function errorDocument(code: string, message: string): string {
|
|
|
106
110
|
return `${JSON.stringify({ ok: false, error: { code, message, ...docs } })}\n`
|
|
107
111
|
}
|
|
108
112
|
|
|
113
|
+
/**
|
|
114
|
+
* What a thrown value says, for a line that names its thrower. A plugin
|
|
115
|
+
* may throw anything: `String(err)` read `[object Object]` for an
|
|
116
|
+
* error-like `{ message }`, `undefined` for a bare `throw`, and itself threw
|
|
117
|
+
* on an object with no prototype. Never throws.
|
|
118
|
+
*/
|
|
119
|
+
export function thrownMessage(err: unknown): string {
|
|
120
|
+
try {
|
|
121
|
+
if (typeof err === 'string') return err === '' ? 'threw an empty string' : err
|
|
122
|
+
if (typeof err === 'object' && err !== null) {
|
|
123
|
+
const message = (err as { message?: unknown }).message
|
|
124
|
+
if (typeof message === 'string' && message !== '') return message
|
|
125
|
+
if (err instanceof Error) return `threw ${err.name} with no message`
|
|
126
|
+
const json = JSON.stringify(err)
|
|
127
|
+
if (json !== undefined) return `threw ${json.length > 200 ? `${json.slice(0, 200)}…` : json}`
|
|
128
|
+
return 'threw an object'
|
|
129
|
+
}
|
|
130
|
+
if (typeof err === 'bigint') return `threw ${err}n`
|
|
131
|
+
return `threw ${String(err)}`
|
|
132
|
+
} catch {
|
|
133
|
+
return 'threw an object'
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
109
137
|
/**
|
|
110
138
|
* `instanceof UserError`, plus the same class arriving from ANOTHER COPY of
|
|
111
139
|
* core. A compiled `vx` binary carries core inside it while a plugin in the
|
|
@@ -164,9 +192,21 @@ export function isFsRefusal(err: unknown): err is NodeJS.ErrnoException {
|
|
|
164
192
|
|
|
165
193
|
export const DISK_FULL_HINT = 'the disk that path is on is full'
|
|
166
194
|
|
|
195
|
+
/** What follows the path when the refused call only reads. */
|
|
196
|
+
const READ_PERMISSION_HINT = 'a path vx must read is not readable by this user'
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Calls that only read. `open` is either, so it keeps the write hint: the
|
|
200
|
+
* reads vx opens name their own refusal (`hashFileFromDisk`).
|
|
201
|
+
*/
|
|
202
|
+
const READ_SYSCALLS = new Set(['scandir', 'stat', 'lstat', 'readlink', 'read', 'realpath'])
|
|
203
|
+
|
|
167
204
|
/** The one-line hint after a refusal's path. */
|
|
168
205
|
export function fsRefusalHint(err: NodeJS.ErrnoException): string {
|
|
169
|
-
|
|
206
|
+
const { syscall } = err
|
|
207
|
+
if (isDiskFull(err)) return DISK_FULL_HINT
|
|
208
|
+
// A listing told to be a write sent the user to chmod the wrong thing.
|
|
209
|
+
return READ_SYSCALLS.has(syscall ?? '') ? READ_PERMISSION_HINT : PERMISSION_HINT
|
|
170
210
|
}
|
|
171
211
|
|
|
172
212
|
/**
|
|
@@ -188,6 +228,7 @@ export function isExecutableMissing(err: unknown): boolean {
|
|
|
188
228
|
export function gitSpawnRefusal(cwd: string): UserError {
|
|
189
229
|
return new UserError(
|
|
190
230
|
`vx requires git: failed to spawn 'git' (working dir: ${cwd}). Install git and re-run.`,
|
|
231
|
+
'VX_E_NO_GIT',
|
|
191
232
|
)
|
|
192
233
|
}
|
|
193
234
|
|
|
@@ -200,6 +241,21 @@ export function notAWorkTree(cwd: string, stderr = ''): UserError {
|
|
|
200
241
|
return new UserError(
|
|
201
242
|
`vx requires git: ${cwd} is not inside a git work tree. ` +
|
|
202
243
|
`Run 'git init' in your workspace root.${stderr ? ` (git: ${stderr})` : ''}`,
|
|
244
|
+
'VX_E_NO_GIT',
|
|
245
|
+
)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* A git that ran in `cwd` and failed: no repository there is
|
|
250
|
+
* `notAWorkTree`, anything else (a corrupt index, a missing object) is git's
|
|
251
|
+
* own failure, `VX_E_GIT`. Every failed `ls-files` read as "not a work
|
|
252
|
+
* tree", telling a user with a broken index to `git init` (2026-10-28).
|
|
253
|
+
*/
|
|
254
|
+
export function gitFailure(cwd: string, args: string, exitCode: number, stderr: string): UserError {
|
|
255
|
+
if (/not a git repository/i.test(stderr)) return notAWorkTree(cwd, stderr)
|
|
256
|
+
return new UserError(
|
|
257
|
+
`git ${args} failed (exit ${exitCode}) in ${cwd}: ${stderr || 'no output'}. Run it there to see why.`,
|
|
258
|
+
'VX_E_GIT',
|
|
203
259
|
)
|
|
204
260
|
}
|
|
205
261
|
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { existsSync, realpathSync } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
1
4
|
/**
|
|
2
5
|
* Every task gets these (`exec/env.ts`), so `hideEnvFromTasks` leaves
|
|
3
6
|
* them. Exported so the schema doc's enumeration of it can be PINNED against the
|
|
@@ -28,4 +31,58 @@ export const ESSENTIAL_ENV: readonly string[] = [
|
|
|
28
31
|
// manager (offline or sandboxed: fails) and pnpm loses its global dir.
|
|
29
32
|
'COREPACK_HOME',
|
|
30
33
|
'PNPM_HOME',
|
|
34
|
+
// The XDG base directories, machine paths as HOME is. Stripped, a task
|
|
35
|
+
// resolved other homes than the shell did: pnpm used another store, and
|
|
36
|
+
// a CLI keeping its login there ran signed out (vercel/turborepo#13145).
|
|
37
|
+
'XDG_CONFIG_HOME',
|
|
38
|
+
'XDG_DATA_HOME',
|
|
39
|
+
'XDG_CACHE_HOME',
|
|
40
|
+
'XDG_STATE_HOME',
|
|
41
|
+
'XDG_RUNTIME_DIR',
|
|
31
42
|
]
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* `PATH` less each `node_modules/.bin` inside `workspaceRoot`. `npm run`
|
|
46
|
+
* in a member puts that member's bins, its parents' and the root's on the
|
|
47
|
+
* PATH vx inherits; a task that kept them ran a sibling's bin, which no
|
|
48
|
+
* key sees. A task gets back the two it may see (`taskBinDirs`). Inside
|
|
49
|
+
* by spelling, or by canonical path: an entry spelled through a symlink
|
|
50
|
+
* (macOS's `/tmp`, a linked home, a shell's `$PWD`) kept a sibling's bin
|
|
51
|
+
* beside a canonical root.
|
|
52
|
+
*/
|
|
53
|
+
export function withoutWorkspaceBins(PATH: string, workspaceRoot: string): string {
|
|
54
|
+
const bin = `${path.sep}node_modules${path.sep}.bin`
|
|
55
|
+
const within = (dir: string, root: string): boolean =>
|
|
56
|
+
dir.startsWith(root.endsWith(path.sep) ? root : root + path.sep)
|
|
57
|
+
let root: string | undefined
|
|
58
|
+
return PATH.split(path.delimiter)
|
|
59
|
+
.filter((entry) => {
|
|
60
|
+
const dir = entry.endsWith(path.sep) ? entry.slice(0, -1) : entry
|
|
61
|
+
if (!dir.endsWith(bin)) return true
|
|
62
|
+
if (within(dir, workspaceRoot)) return false
|
|
63
|
+
// Relative, it is the task's cwd's: not a place this check can name.
|
|
64
|
+
if (!path.isAbsolute(dir)) return true
|
|
65
|
+
const real = canonical(dir)
|
|
66
|
+
if (real === undefined) return true
|
|
67
|
+
root ??= canonical(workspaceRoot) ?? workspaceRoot
|
|
68
|
+
return !within(real, root)
|
|
69
|
+
})
|
|
70
|
+
.join(path.delimiter)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* `p`'s canonical spelling; undefined when it is not there, and a missing
|
|
75
|
+
* directory on PATH resolves nothing. Asked per call, never memoized: a
|
|
76
|
+
* task may create the bin directory (an install) or a link above it, and a
|
|
77
|
+
* fact about a file a task can write is not reused across tasks.
|
|
78
|
+
*/
|
|
79
|
+
function canonical(p: string): string | undefined {
|
|
80
|
+
// A stat, not a throw: most such entries are npm's ancestors of the
|
|
81
|
+
// root, and an ENOENT thrown per entry was 3 µs a spawn each.
|
|
82
|
+
if (!existsSync(p)) return undefined
|
|
83
|
+
try {
|
|
84
|
+
return realpathSync.native(p)
|
|
85
|
+
} catch {
|
|
86
|
+
return undefined
|
|
87
|
+
}
|
|
88
|
+
}
|
package/src/util/git-ignored.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Whether git ignores everything an output glob can write (
|
|
1
|
+
// Whether git ignores everything an output glob can write (2026-10-15):
|
|
2
2
|
// a key reads only what git lists, so such an output never reaches a
|
|
3
3
|
// reader's key. Asked of git, never read from a `.gitignore`: git alone
|
|
4
4
|
// knows every rule source (nested files, `info/exclude`, `core.excludesFile`)
|
package/src/util/hash.ts
CHANGED
|
@@ -32,3 +32,38 @@ export function xxh3(input: string | Uint8Array, seed: bigint = 0n): bigint {
|
|
|
32
32
|
export function xxh3hex(input: string | Uint8Array, seed: bigint = 0n): string {
|
|
33
33
|
return xxh3(input, seed).toString(16).padStart(16, '0')
|
|
34
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* `v` with every object's keys sorted, all the way down: `v` itself when
|
|
37
|
+
* they are already, a copy when not. A key hashes a config's JSON, and the
|
|
38
|
+
* order a file or a plugin spells its keys in says nothing. Checked first,
|
|
39
|
+
* a config the lock wrote sorted costs a walk, not a copy.
|
|
40
|
+
*/
|
|
41
|
+
export function keysSorted<T>(v: T): T {
|
|
42
|
+
return isSorted(v) ? v : (sortedDeep(v) as T)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isSorted(v: unknown): boolean {
|
|
46
|
+
if (v === null || typeof v !== 'object') return true
|
|
47
|
+
if (Array.isArray(v)) return v.every(isSorted)
|
|
48
|
+
let prev: string | undefined
|
|
49
|
+
for (const k in v) {
|
|
50
|
+
if (prev !== undefined && prev > k) return false
|
|
51
|
+
prev = k
|
|
52
|
+
if (!isSorted((v as Record<string, unknown>)[k])) return false
|
|
53
|
+
}
|
|
54
|
+
return true
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function sortedDeep(v: unknown): unknown {
|
|
58
|
+
if (v === null || typeof v !== 'object') return v
|
|
59
|
+
if (Array.isArray(v)) return v.map(sortedDeep)
|
|
60
|
+
const out: Record<string, unknown> = {}
|
|
61
|
+
for (const k of Object.keys(v).sort()) {
|
|
62
|
+
const value = sortedDeep((v as Record<string, unknown>)[k])
|
|
63
|
+
// Assigned, `__proto__` sets the copy's prototype and leaves the JSON.
|
|
64
|
+
if (k === '__proto__')
|
|
65
|
+
Object.defineProperty(out, k, { value, enumerable: true, writable: true, configurable: true })
|
|
66
|
+
else out[k] = value
|
|
67
|
+
}
|
|
68
|
+
return out
|
|
69
|
+
}
|
package/src/util/hide-env.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { readFileSync, realpathSync } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
1
3
|
import { ESSENTIAL_ENV } from './essential-env.js'
|
|
2
4
|
|
|
3
5
|
const PR_SET_DUMPABLE = 4
|
|
@@ -5,8 +7,8 @@ const PR_SET_DUMPABLE = 4
|
|
|
5
7
|
/**
|
|
6
8
|
* Hide vx's environment from a same-user task. A task gets only declared
|
|
7
9
|
* variables, but vx itself holds the rest. Not a boundary (`exec.sandbox`
|
|
8
|
-
* is): it hides the environment, not files. On every verb
|
|
9
|
-
* 2026-10-13).
|
|
10
|
+
* is): it hides the environment, not files. On every verb
|
|
11
|
+
* (2026-10-13).
|
|
10
12
|
*
|
|
11
13
|
* Linux: make vx non-dumpable, so its `/proc/<pid>/environ` (and ptrace)
|
|
12
14
|
* close to the task. A child is dumpable again after `exec`, so tasks are
|
|
@@ -16,13 +18,16 @@ const PR_SET_DUMPABLE = 4
|
|
|
16
18
|
*
|
|
17
19
|
* macOS: `ps eww` reads the env strings the kernel copied above the
|
|
18
20
|
* stack, and no flag closes that to the same user, so vx zeroes the
|
|
19
|
-
* values there (
|
|
21
|
+
* values there (2026-10-13: "Do the override then on macOS").
|
|
20
22
|
* Bun copied the env at startup, so `process.env`, and every task built
|
|
21
23
|
* from it, keeps them.
|
|
22
24
|
*/
|
|
23
25
|
export async function hideEnvFromTasks(): Promise<boolean> {
|
|
24
26
|
if (process.platform === 'darwin') return wipeStartupEnv()
|
|
25
|
-
|
|
27
|
+
// `debug.dumpable` is a bool (util sits below src/settings.ts, so it reads
|
|
28
|
+
// it itself, as util/timing.ts does): `0` and `false` hide too.
|
|
29
|
+
const dumpable = process.env['VX_DEBUG_DUMPABLE']
|
|
30
|
+
if (process.platform !== 'linux' || dumpable === 'true' || dumpable === '1') return false
|
|
26
31
|
try {
|
|
27
32
|
const { dlopen, FFIType } = await import('bun:ffi')
|
|
28
33
|
const libc = dlopen('libc.so.6', {
|
|
@@ -97,11 +102,46 @@ export function gitEnv(): Record<string, string> {
|
|
|
97
102
|
if (
|
|
98
103
|
name.startsWith('GIT_') ||
|
|
99
104
|
name.startsWith('LC_') ||
|
|
100
|
-
name === 'XDG_CONFIG_HOME' ||
|
|
101
105
|
name === 'LANGUAGE' ||
|
|
102
106
|
ESSENTIAL_ENV.includes(name)
|
|
103
107
|
)
|
|
104
108
|
env[name] = value
|
|
105
109
|
}
|
|
110
|
+
if (env['GIT_DIR'] !== undefined && env['GIT_WORK_TREE'] === undefined) {
|
|
111
|
+
if (rediscovers(env['GIT_DIR'])) delete env['GIT_DIR']
|
|
112
|
+
}
|
|
106
113
|
return env
|
|
107
114
|
}
|
|
115
|
+
|
|
116
|
+
const rediscovered = new Map<string, boolean>()
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Whether a plain `git` where vx started finds the repository `gitDir`
|
|
120
|
+
* names anyway. A linked worktree's hook exports its gitdir as GIT_DIR and
|
|
121
|
+
* no GIT_WORK_TREE, and git then takes ITS cwd as the work tree's top: vx
|
|
122
|
+
* runs git in the workspace root, a subfolder there, and git read every
|
|
123
|
+
* file as untracked. Without the variable git finds the top itself. A
|
|
124
|
+
* GIT_DIR naming any other repository (a bare one, dotfiles) is kept.
|
|
125
|
+
*/
|
|
126
|
+
function rediscovers(gitDir: string): boolean {
|
|
127
|
+
const cwd = process.cwd()
|
|
128
|
+
const key = `${cwd}\0${gitDir}`
|
|
129
|
+
let hit = rediscovered.get(key)
|
|
130
|
+
if (hit === undefined) {
|
|
131
|
+
hit = false
|
|
132
|
+
try {
|
|
133
|
+
const abs = path.resolve(cwd, gitDir)
|
|
134
|
+
let dotGit = abs
|
|
135
|
+
// A linked worktree's admin dir names its checkout's `.git` file.
|
|
136
|
+
try {
|
|
137
|
+
dotGit = path.resolve(abs, readFileSync(path.join(abs, 'gitdir'), 'utf8').trim())
|
|
138
|
+
} catch {}
|
|
139
|
+
if (path.basename(dotGit) === '.git') {
|
|
140
|
+
const rel = path.relative(realpathSync(path.dirname(dotGit)), realpathSync(cwd))
|
|
141
|
+
hit = !rel.startsWith('..') && !path.isAbsolute(rel)
|
|
142
|
+
}
|
|
143
|
+
} catch {}
|
|
144
|
+
rediscovered.set(key, hit)
|
|
145
|
+
}
|
|
146
|
+
return hit
|
|
147
|
+
}
|