@loomhelix/deptrail 0.1.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/LICENSE +21 -0
- package/README.md +421 -0
- package/dist/actions/action-error.js +11 -0
- package/dist/actions/action-jobs.js +199 -0
- package/dist/actions/action-service.js +268 -0
- package/dist/actions/apply.js +120 -0
- package/dist/actions/availability.js +79 -0
- package/dist/actions/change-sets.js +117 -0
- package/dist/actions/dry-run.js +287 -0
- package/dist/actions/edge-moves.js +108 -0
- package/dist/actions/edit-plan.js +179 -0
- package/dist/actions/file-scope.js +118 -0
- package/dist/actions/fix-options.js +141 -0
- package/dist/actions/goal-check.js +80 -0
- package/dist/actions/install.js +27 -0
- package/dist/actions/job-registry.js +104 -0
- package/dist/actions/json-edit.js +189 -0
- package/dist/actions/labels.js +35 -0
- package/dist/actions/node-actions.js +102 -0
- package/dist/actions/overrides-location.js +59 -0
- package/dist/actions/parent-chain.js +210 -0
- package/dist/actions/range-style.js +38 -0
- package/dist/actions/recipes.js +31 -0
- package/dist/actions/root-files.js +30 -0
- package/dist/actions/sandbox.js +175 -0
- package/dist/actions/set-range.js +59 -0
- package/dist/actions/shared-packages.js +50 -0
- package/dist/actions/text-diff.js +203 -0
- package/dist/actions/types.js +1 -0
- package/dist/actions/update-options.js +232 -0
- package/dist/actions/workspace-edit.js +33 -0
- package/dist/adapters/command-runner.js +127 -0
- package/dist/adapters/manifest-only.js +20 -0
- package/dist/adapters/node-modules.js +148 -0
- package/dist/adapters/npm-lock.js +149 -0
- package/dist/adapters/package-manifest.js +57 -0
- package/dist/adapters/pnpm-lock.js +177 -0
- package/dist/adapters/resolve-today.js +93 -0
- package/dist/cli/cli.js +227 -0
- package/dist/cli/open-browser.js +14 -0
- package/dist/cli/package-version.js +6 -0
- package/dist/cli/usage-text.js +27 -0
- package/dist/config/glob.js +119 -0
- package/dist/config/npmrc.js +116 -0
- package/dist/config/root-config.js +103 -0
- package/dist/config/secrets.js +18 -0
- package/dist/discovery/find-roots.js +107 -0
- package/dist/enrich/credentials.js +143 -0
- package/dist/enrich/enrichment-cache.js +42 -0
- package/dist/enrich/enrichment-keys.js +71 -0
- package/dist/enrich/enrichment-service.js +399 -0
- package/dist/enrich/enrichment-status.js +86 -0
- package/dist/enrich/enrichment-store.js +143 -0
- package/dist/enrich/http.js +201 -0
- package/dist/enrich/on-demand.js +220 -0
- package/dist/enrich/osv-client.js +260 -0
- package/dist/enrich/registry-client.js +123 -0
- package/dist/enrich/types.js +1 -0
- package/dist/git/blob-reader.js +180 -0
- package/dist/git/git-commands.js +32 -0
- package/dist/git/git-process.js +71 -0
- package/dist/git/git-repository.js +136 -0
- package/dist/git/ref-list.js +78 -0
- package/dist/git/tree-index.js +87 -0
- package/dist/graph/global-index.js +19 -0
- package/dist/graph/root-graph-builder.js +114 -0
- package/dist/graph/serialize.js +19 -0
- package/dist/model.js +2 -0
- package/dist/query/children.js +38 -0
- package/dist/query/compare-shape.js +13 -0
- package/dist/query/compare.js +245 -0
- package/dist/query/decorations.js +121 -0
- package/dist/query/dependents.js +135 -0
- package/dist/query/duplicates.js +23 -0
- package/dist/query/enrichment-lookup.js +78 -0
- package/dist/query/fixes.js +60 -0
- package/dist/query/graph-diff.js +42 -0
- package/dist/query/issues.js +235 -0
- package/dist/query/lookup.js +49 -0
- package/dist/query/node-fix.js +46 -0
- package/dist/query/package-insight.js +112 -0
- package/dist/query/search.js +61 -0
- package/dist/query/subgraph.js +107 -0
- package/dist/query/versions.js +103 -0
- package/dist/scan/build-root.js +137 -0
- package/dist/scan/cache-shape.js +52 -0
- package/dist/scan/scan-cache.js +32 -0
- package/dist/scan/scan-session.js +127 -0
- package/dist/scan/source-registry.js +55 -0
- package/dist/server/auth.js +45 -0
- package/dist/server/branches.js +105 -0
- package/dist/server/error-log.js +9 -0
- package/dist/server/root-segment.js +13 -0
- package/dist/server/router.js +474 -0
- package/dist/server/server.js +166 -0
- package/dist/server/source-key.js +1 -0
- package/dist/server/static.js +59 -0
- package/dist/sources/file-reader.js +55 -0
- package/dist/sources/git-reader.js +45 -0
- package/dist/ui/assets/index-C4uyB5v3.css +1 -0
- package/dist/ui/assets/index-DYAG2LM-.js +51 -0
- package/dist/ui/index.html +15 -0
- package/package.json +72 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { HttpError } from "./http.js";
|
|
2
|
+
export const CORGI_ACCEPT = 'application/vnd.npm.install-v1+json; q=1.0, application/json; q=0.8, */*';
|
|
3
|
+
const UNAUTHORIZED_STATUS = 401;
|
|
4
|
+
const FORBIDDEN_STATUS = 403;
|
|
5
|
+
const MALFORMED_REASON = 'returned a malformed packument';
|
|
6
|
+
const FAILURE_REASON_BY_KIND = {
|
|
7
|
+
timeout: 'did not respond within 30 s',
|
|
8
|
+
network: 'is unreachable',
|
|
9
|
+
'too-large': 'returned more than 50 MB',
|
|
10
|
+
redirects: 'redirected too many times',
|
|
11
|
+
aborted: 'request was canceled',
|
|
12
|
+
};
|
|
13
|
+
const SCOPE_PREFIX = '@';
|
|
14
|
+
const ESCAPED_SCOPE_SEPARATOR = '%2f';
|
|
15
|
+
function escapedNamePart(part) {
|
|
16
|
+
const escaped = encodeURIComponent(part);
|
|
17
|
+
return /^\.+$/.test(escaped) ? escaped.replace(/\./g, '%252E') : escaped;
|
|
18
|
+
}
|
|
19
|
+
function escapedPackageName(name) {
|
|
20
|
+
const separator = name.indexOf('/');
|
|
21
|
+
if (!name.startsWith(SCOPE_PREFIX) || separator <= SCOPE_PREFIX.length)
|
|
22
|
+
return escapedNamePart(name);
|
|
23
|
+
const scope = escapedNamePart(name.slice(SCOPE_PREFIX.length, separator));
|
|
24
|
+
return `${SCOPE_PREFIX}${scope}${ESCAPED_SCOPE_SEPARATOR}${encodeURIComponent(name.slice(separator + 1))}`;
|
|
25
|
+
}
|
|
26
|
+
export function packumentUrl(registry, name) {
|
|
27
|
+
const base = registry.endsWith('/') ? registry : `${registry}/`;
|
|
28
|
+
return `${base}${escapedPackageName(name)}`;
|
|
29
|
+
}
|
|
30
|
+
function isPlainObject(value) {
|
|
31
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
32
|
+
}
|
|
33
|
+
function isStringRecord(value) {
|
|
34
|
+
return isPlainObject(value) && Object.values(value).every((entry) => typeof entry === 'string');
|
|
35
|
+
}
|
|
36
|
+
export function isPackageMeta(value) {
|
|
37
|
+
return (isPlainObject(value) &&
|
|
38
|
+
isStringRecord(value.distTags) &&
|
|
39
|
+
Array.isArray(value.versions) &&
|
|
40
|
+
value.versions.every((version) => typeof version === 'string') &&
|
|
41
|
+
isStringRecord(value.deprecated) &&
|
|
42
|
+
isPlainObject(value.manifests) &&
|
|
43
|
+
Object.values(value.manifests).every(isStringRecord));
|
|
44
|
+
}
|
|
45
|
+
export function metaCovers(meta, versionsInUse) {
|
|
46
|
+
const known = new Set(meta.versions);
|
|
47
|
+
return versionsInUse.every((version) => !known.has(version) || version in meta.manifests);
|
|
48
|
+
}
|
|
49
|
+
export function failureReason(error) {
|
|
50
|
+
return error instanceof HttpError ? FAILURE_REASON_BY_KIND[error.kind] : FAILURE_REASON_BY_KIND.network;
|
|
51
|
+
}
|
|
52
|
+
function declaredRanges(version) {
|
|
53
|
+
const merged = {};
|
|
54
|
+
for (const field of ['peerDependencies', 'optionalDependencies', 'dependencies']) {
|
|
55
|
+
const ranges = version[field];
|
|
56
|
+
if (!isPlainObject(ranges))
|
|
57
|
+
continue;
|
|
58
|
+
for (const [name, range] of Object.entries(ranges)) {
|
|
59
|
+
if (typeof range === 'string')
|
|
60
|
+
merged[name] = range;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return merged;
|
|
64
|
+
}
|
|
65
|
+
function toPackageMeta(packument, versionsInUse, allManifests) {
|
|
66
|
+
if (!isPlainObject(packument) || !isPlainObject(packument.versions))
|
|
67
|
+
return undefined;
|
|
68
|
+
const distTags = isStringRecord(packument['dist-tags']) ? packument['dist-tags'] : {};
|
|
69
|
+
const deprecated = {};
|
|
70
|
+
const manifests = {};
|
|
71
|
+
const inUse = new Set(versionsInUse);
|
|
72
|
+
for (const [version, entry] of Object.entries(packument.versions)) {
|
|
73
|
+
if (!isPlainObject(entry))
|
|
74
|
+
continue;
|
|
75
|
+
if (typeof entry.deprecated === 'string')
|
|
76
|
+
deprecated[version] = entry.deprecated;
|
|
77
|
+
if (allManifests || inUse.has(version))
|
|
78
|
+
manifests[version] = declaredRanges(entry);
|
|
79
|
+
}
|
|
80
|
+
return { distTags, versions: Object.keys(packument.versions), deprecated, manifests };
|
|
81
|
+
}
|
|
82
|
+
function parseJson(text) {
|
|
83
|
+
try {
|
|
84
|
+
return JSON.parse(text);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function failed(reason, authRejected = false) {
|
|
91
|
+
return { state: 'failed', reason, authRejected };
|
|
92
|
+
}
|
|
93
|
+
export async function fetchPackageMeta(http, request) {
|
|
94
|
+
const headers = { Accept: CORGI_ACCEPT };
|
|
95
|
+
if (request.etag !== undefined)
|
|
96
|
+
headers['If-None-Match'] = request.etag;
|
|
97
|
+
let response;
|
|
98
|
+
try {
|
|
99
|
+
response = await http.request({
|
|
100
|
+
url: packumentUrl(request.registry, request.name),
|
|
101
|
+
headers,
|
|
102
|
+
authorizationFor: request.authorizationFor,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
return failed(failureReason(error));
|
|
107
|
+
}
|
|
108
|
+
if (response.status === 304)
|
|
109
|
+
return { state: 'not-modified' };
|
|
110
|
+
if (response.status === 404)
|
|
111
|
+
return { state: 'not-found' };
|
|
112
|
+
if (response.status === UNAUTHORIZED_STATUS)
|
|
113
|
+
return failed(`rejected credentials (${response.status})`, true);
|
|
114
|
+
if (response.status === FORBIDDEN_STATUS)
|
|
115
|
+
return failed(`rejected credentials (${response.status})`);
|
|
116
|
+
if (response.status < 200 || response.status >= 300)
|
|
117
|
+
return failed(`responded with HTTP ${response.status}`);
|
|
118
|
+
const meta = toPackageMeta(parseJson(response.text), request.versionsInUse, request.allManifests === true);
|
|
119
|
+
if (meta === undefined)
|
|
120
|
+
return failed(MALFORMED_REASON);
|
|
121
|
+
const etag = response.headers.get('etag') ?? undefined;
|
|
122
|
+
return etag === undefined ? { state: 'ok', meta } : { state: 'ok', meta, etag };
|
|
123
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const SEVERITY_ORDER = ['critical', 'high', 'moderate', 'unknown', 'low'];
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { InvalidObjectIdError, isObjectId } from "./git-commands.js";
|
|
2
|
+
const NEWLINE = 0x0a;
|
|
3
|
+
const STOPPED_PREFIX = 'git cat-file stopped: ';
|
|
4
|
+
const BLOB_TYPE = 'blob';
|
|
5
|
+
const MISSING_MARKER = 'missing';
|
|
6
|
+
const SEPARATOR_BYTES = 1;
|
|
7
|
+
const MAX_HEADER_BYTES = 512;
|
|
8
|
+
const MALFORMED_RESPONSE = 'malformed response';
|
|
9
|
+
const DIGITS_ONLY = /^\d+$/;
|
|
10
|
+
function describeExit(value) {
|
|
11
|
+
return typeof value === 'number' ? `exited with code ${value}` : 'exited';
|
|
12
|
+
}
|
|
13
|
+
export function createBlobReader(start, limits) {
|
|
14
|
+
let current = null;
|
|
15
|
+
let closed = false;
|
|
16
|
+
let pending = [];
|
|
17
|
+
let chunks = [];
|
|
18
|
+
let bufferedBytes = 0;
|
|
19
|
+
let bodyBytes = null;
|
|
20
|
+
let bytesToSkip = 0;
|
|
21
|
+
let timer = null;
|
|
22
|
+
const clearTimer = () => {
|
|
23
|
+
if (timer)
|
|
24
|
+
clearTimeout(timer);
|
|
25
|
+
timer = null;
|
|
26
|
+
};
|
|
27
|
+
const resetStream = () => {
|
|
28
|
+
chunks = [];
|
|
29
|
+
bufferedBytes = 0;
|
|
30
|
+
bodyBytes = null;
|
|
31
|
+
bytesToSkip = 0;
|
|
32
|
+
};
|
|
33
|
+
const fail = (reason) => {
|
|
34
|
+
const process = current;
|
|
35
|
+
const rejected = pending;
|
|
36
|
+
current = null;
|
|
37
|
+
pending = [];
|
|
38
|
+
clearTimer();
|
|
39
|
+
resetStream();
|
|
40
|
+
process?.kill('SIGTERM');
|
|
41
|
+
const error = new Error(`${STOPPED_PREFIX}${reason}`);
|
|
42
|
+
for (const read of rejected)
|
|
43
|
+
read.reject(error);
|
|
44
|
+
};
|
|
45
|
+
const armTimer = () => {
|
|
46
|
+
clearTimer();
|
|
47
|
+
if (pending.length === 0)
|
|
48
|
+
return;
|
|
49
|
+
timer = setTimeout(() => fail(`no response within ${limits.responseTimeoutMs} ms`), limits.responseTimeoutMs);
|
|
50
|
+
};
|
|
51
|
+
const settle = (blob) => {
|
|
52
|
+
pending.shift()?.resolve(blob);
|
|
53
|
+
};
|
|
54
|
+
const skipBytes = () => {
|
|
55
|
+
while (bytesToSkip > 0 && chunks.length > 0) {
|
|
56
|
+
const head = chunks[0];
|
|
57
|
+
if (head.length <= bytesToSkip) {
|
|
58
|
+
bytesToSkip -= head.length;
|
|
59
|
+
bufferedBytes -= head.length;
|
|
60
|
+
chunks.shift();
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
chunks[0] = head.subarray(bytesToSkip);
|
|
64
|
+
bufferedBytes -= bytesToSkip;
|
|
65
|
+
bytesToSkip = 0;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
const keepRest = (rest) => {
|
|
70
|
+
chunks = rest.length > 0 ? [rest] : [];
|
|
71
|
+
bufferedBytes = rest.length;
|
|
72
|
+
};
|
|
73
|
+
const readHeader = () => {
|
|
74
|
+
const merged = Buffer.concat(chunks);
|
|
75
|
+
const newlineAt = merged.indexOf(NEWLINE);
|
|
76
|
+
if (newlineAt < 0) {
|
|
77
|
+
if (merged.length > MAX_HEADER_BYTES)
|
|
78
|
+
fail(MALFORMED_RESPONSE);
|
|
79
|
+
else
|
|
80
|
+
keepRest(merged);
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
const fields = merged.toString('latin1', 0, newlineAt).split(' ');
|
|
84
|
+
const [, type, sizeText] = fields;
|
|
85
|
+
const size = Number(sizeText);
|
|
86
|
+
const isMissing = fields.length === 2 && type === MISSING_MARKER;
|
|
87
|
+
const isTyped = fields.length === 3 && DIGITS_ONLY.test(sizeText ?? '') && Number.isSafeInteger(size);
|
|
88
|
+
if (pending.length === 0 || (!isMissing && !isTyped)) {
|
|
89
|
+
fail(MALFORMED_RESPONSE);
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
keepRest(merged.subarray(newlineAt + SEPARATOR_BYTES));
|
|
93
|
+
if (isMissing) {
|
|
94
|
+
settle(null);
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
if (type === BLOB_TYPE && size <= limits.maxBlobBytes) {
|
|
98
|
+
bodyBytes = size;
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
settle(null);
|
|
102
|
+
bytesToSkip = size + SEPARATOR_BYTES;
|
|
103
|
+
return true;
|
|
104
|
+
};
|
|
105
|
+
const readBody = (size) => {
|
|
106
|
+
if (bufferedBytes < size + SEPARATOR_BYTES)
|
|
107
|
+
return false;
|
|
108
|
+
const merged = Buffer.concat(chunks);
|
|
109
|
+
const body = Buffer.from(merged.subarray(0, size));
|
|
110
|
+
keepRest(merged.subarray(size + SEPARATOR_BYTES));
|
|
111
|
+
bodyBytes = null;
|
|
112
|
+
settle(body);
|
|
113
|
+
return true;
|
|
114
|
+
};
|
|
115
|
+
const drain = () => {
|
|
116
|
+
for (;;) {
|
|
117
|
+
skipBytes();
|
|
118
|
+
if (bytesToSkip > 0 || bufferedBytes === 0)
|
|
119
|
+
return;
|
|
120
|
+
const progressed = bodyBytes === null ? readHeader() : readBody(bodyBytes);
|
|
121
|
+
if (!progressed)
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
const receive = (chunk) => {
|
|
126
|
+
chunks.push(chunk);
|
|
127
|
+
bufferedBytes += chunk.length;
|
|
128
|
+
drain();
|
|
129
|
+
armTimer();
|
|
130
|
+
};
|
|
131
|
+
const launch = () => {
|
|
132
|
+
const process = start();
|
|
133
|
+
current = process;
|
|
134
|
+
process.stdin.on('error', (error) => {
|
|
135
|
+
if (current === process)
|
|
136
|
+
fail(error.message);
|
|
137
|
+
});
|
|
138
|
+
process.stdout.on('data', (chunk) => {
|
|
139
|
+
if (current === process)
|
|
140
|
+
receive(chunk);
|
|
141
|
+
});
|
|
142
|
+
process.on('exit', (code) => {
|
|
143
|
+
if (current !== process)
|
|
144
|
+
return;
|
|
145
|
+
if (pending.length > 0)
|
|
146
|
+
fail(describeExit(code));
|
|
147
|
+
else
|
|
148
|
+
current = null;
|
|
149
|
+
});
|
|
150
|
+
process.on('error', (error) => {
|
|
151
|
+
if (current !== process)
|
|
152
|
+
return;
|
|
153
|
+
if (pending.length > 0)
|
|
154
|
+
fail(error instanceof Error ? error.message : 'process error');
|
|
155
|
+
else
|
|
156
|
+
current = null;
|
|
157
|
+
});
|
|
158
|
+
return process;
|
|
159
|
+
};
|
|
160
|
+
return {
|
|
161
|
+
read(blobId) {
|
|
162
|
+
return new Promise((resolve, reject) => {
|
|
163
|
+
if (!isObjectId(blobId))
|
|
164
|
+
throw new InvalidObjectIdError(blobId);
|
|
165
|
+
if (closed)
|
|
166
|
+
throw new Error(`${STOPPED_PREFIX}closed`);
|
|
167
|
+
const process = current ?? launch();
|
|
168
|
+
const wasIdle = pending.length === 0;
|
|
169
|
+
pending.push({ resolve, reject });
|
|
170
|
+
if (wasIdle)
|
|
171
|
+
armTimer();
|
|
172
|
+
process.stdin.write(`${blobId}\n`);
|
|
173
|
+
});
|
|
174
|
+
},
|
|
175
|
+
close() {
|
|
176
|
+
closed = true;
|
|
177
|
+
fail('closed');
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export const OBJECT_ID = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
2
|
+
export function isObjectId(value) {
|
|
3
|
+
return OBJECT_ID.test(value);
|
|
4
|
+
}
|
|
5
|
+
export class InvalidObjectIdError extends Error {
|
|
6
|
+
constructor(value) {
|
|
7
|
+
super(`not a git object id: ${value}`);
|
|
8
|
+
this.name = 'InvalidObjectIdError';
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
function requireObjectId(value) {
|
|
12
|
+
if (!isObjectId(value))
|
|
13
|
+
throw new InvalidObjectIdError(value);
|
|
14
|
+
return value;
|
|
15
|
+
}
|
|
16
|
+
export const REF_FORMAT = '%(refname)%00%(objecttype)%00%(objectname)%00%(*objecttype)%00%(*objectname)%00%(committerdate:unix)%00%(*committerdate:unix)%00%(symref)';
|
|
17
|
+
export const GIT_COMMANDS = {
|
|
18
|
+
version: ['--version'],
|
|
19
|
+
detect: ['rev-parse', '--show-toplevel', '--show-prefix', '--git-common-dir'],
|
|
20
|
+
symbolicHead: ['symbolic-ref', '-q', 'HEAD'],
|
|
21
|
+
headCommit: ['rev-parse', '-q', '--verify', 'HEAD^{commit}'],
|
|
22
|
+
refs: ['for-each-ref', `--format=${REF_FORMAT}`, 'refs/heads', 'refs/remotes', 'refs/tags'],
|
|
23
|
+
blobs: ['cat-file', '--batch'],
|
|
24
|
+
};
|
|
25
|
+
export function treeArgs(commit) {
|
|
26
|
+
return ['ls-tree', '-r', '-t', '-z', '--full-tree', '--end-of-options', requireObjectId(commit)];
|
|
27
|
+
}
|
|
28
|
+
export function mergeBaseArgs(left, right) {
|
|
29
|
+
return ['merge-base', '--end-of-options', requireObjectId(left), requireObjectId(right)];
|
|
30
|
+
}
|
|
31
|
+
export const GIT_TIMEOUTS = { quick: 10_000, refs: 30_000, slow: 120_000, blob: 60_000 };
|
|
32
|
+
export const GIT_LIMITS = { treeEntries: 2_000_000, blobBytes: 268_435_456, outputBytes: 536_870_912 };
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { spawn as nodeSpawn } from 'node:child_process';
|
|
2
|
+
export class GitFailure extends Error {
|
|
3
|
+
kind;
|
|
4
|
+
constructor(kind, message) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = 'GitFailure';
|
|
7
|
+
this.kind = kind;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
const GIT_VARIABLE_PREFIX = 'GIT_';
|
|
11
|
+
const BYTES_PER_MEGABYTE = 1024 * 1024;
|
|
12
|
+
const MILLISECONDS_PER_SECOND = 1000;
|
|
13
|
+
const NOT_STARTED_EXIT_CODE = 1;
|
|
14
|
+
const FIXED_GIT_ENVIRONMENT = { GIT_TERMINAL_PROMPT: '0', GIT_OPTIONAL_LOCKS: '0', GIT_NO_LAZY_FETCH: '1', LC_ALL: 'C' };
|
|
15
|
+
const running = new Set();
|
|
16
|
+
const defaultSpawn = (command, args, options) => nodeSpawn(command, [...args], options);
|
|
17
|
+
export function gitEnvironment(env) {
|
|
18
|
+
const cleaned = {};
|
|
19
|
+
for (const [name, value] of Object.entries(env)) {
|
|
20
|
+
if (value !== undefined && !name.startsWith(GIT_VARIABLE_PREFIX))
|
|
21
|
+
cleaned[name] = value;
|
|
22
|
+
}
|
|
23
|
+
return { ...cleaned, ...FIXED_GIT_ENVIRONMENT };
|
|
24
|
+
}
|
|
25
|
+
export function startGit(options, args) {
|
|
26
|
+
const spawn = options.spawn ?? defaultSpawn;
|
|
27
|
+
const child = spawn('git', args, { cwd: options.cwd, env: gitEnvironment(options.env), shell: false, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
28
|
+
running.add(child);
|
|
29
|
+
child.once('close', () => running.delete(child));
|
|
30
|
+
child.once('error', () => running.delete(child));
|
|
31
|
+
return child;
|
|
32
|
+
}
|
|
33
|
+
export function runGit(options, args, limits) {
|
|
34
|
+
return new Promise((resolve, reject) => {
|
|
35
|
+
const child = startGit(options, args);
|
|
36
|
+
child.stdin?.on('error', () => undefined);
|
|
37
|
+
child.stdin?.end();
|
|
38
|
+
const stdoutChunks = [];
|
|
39
|
+
const stderrChunks = [];
|
|
40
|
+
let stdoutBytes = 0;
|
|
41
|
+
let finished = false;
|
|
42
|
+
const finish = (settleOutcome) => {
|
|
43
|
+
if (finished)
|
|
44
|
+
return;
|
|
45
|
+
finished = true;
|
|
46
|
+
clearTimeout(timer);
|
|
47
|
+
running.delete(child);
|
|
48
|
+
settleOutcome();
|
|
49
|
+
};
|
|
50
|
+
const abort = (failure) => finish(() => {
|
|
51
|
+
child.kill('SIGTERM');
|
|
52
|
+
reject(failure);
|
|
53
|
+
});
|
|
54
|
+
const timer = setTimeout(() => abort(new GitFailure('timeout', `git did not finish within ${limits.timeoutMs / MILLISECONDS_PER_SECOND} s`)), limits.timeoutMs);
|
|
55
|
+
child.stdout?.on('data', (chunk) => {
|
|
56
|
+
stdoutBytes += chunk.length;
|
|
57
|
+
if (stdoutBytes > limits.maxBytes) {
|
|
58
|
+
abort(new GitFailure('too-large', `git output exceeded ${limits.maxBytes / BYTES_PER_MEGABYTE} MB`));
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
stdoutChunks.push(chunk);
|
|
62
|
+
});
|
|
63
|
+
child.stderr?.on('data', (chunk) => stderrChunks.push(chunk));
|
|
64
|
+
child.on('error', (error) => finish(() => reject(error.code === 'ENOENT' ? new GitFailure('not-found', 'git was not found') : new GitFailure('spawn', error.message))));
|
|
65
|
+
child.on('close', (code) => finish(() => resolve({ code: code ?? NOT_STARTED_EXIT_CODE, stdout: Buffer.concat(stdoutChunks), stderr: Buffer.concat(stderrChunks).toString('utf8') })));
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
export function killGitProcesses() {
|
|
69
|
+
for (const child of running)
|
|
70
|
+
child.kill('SIGTERM');
|
|
71
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { readdir } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { createBlobReader } from "./blob-reader.js";
|
|
4
|
+
import { GIT_COMMANDS, GIT_LIMITS, GIT_TIMEOUTS, mergeBaseArgs, treeArgs } from "./git-commands.js";
|
|
5
|
+
import { GitFailure, runGit, startGit } from "./git-process.js";
|
|
6
|
+
import { parseRefs } from "./ref-list.js";
|
|
7
|
+
import { parseTree } from "./tree-index.js";
|
|
8
|
+
const QUICK_OUTPUT_BYTES = 1024 * 1024;
|
|
9
|
+
const MINIMUM_VERSION = [2, 24, 0];
|
|
10
|
+
const LAZY_FETCH_VERSION = [2, 44, 0];
|
|
11
|
+
const VERSION_PATTERN = /git version (\d+)\.(\d+)(?:\.(\d+))?/;
|
|
12
|
+
const NOT_A_REPOSITORY = 'not a git repository';
|
|
13
|
+
const PROMISOR_SUFFIX = '.promisor';
|
|
14
|
+
const NO_MERGE_BASE_EXIT_CODE = 1;
|
|
15
|
+
const DETECT_LINE_COUNT = 3;
|
|
16
|
+
export function parseGitVersion(output) {
|
|
17
|
+
const match = VERSION_PATTERN.exec(output);
|
|
18
|
+
if (!match)
|
|
19
|
+
return undefined;
|
|
20
|
+
return [Number(match[1]), Number(match[2]), Number(match[3] ?? 0)];
|
|
21
|
+
}
|
|
22
|
+
export function isGitRepository(value) {
|
|
23
|
+
return !('unavailable' in value);
|
|
24
|
+
}
|
|
25
|
+
function isBelow(version, limit) {
|
|
26
|
+
for (let index = 0; index < limit.length; index++) {
|
|
27
|
+
if (version[index] !== limit[index])
|
|
28
|
+
return version[index] < limit[index];
|
|
29
|
+
}
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
async function listDirectoryOrEmpty(directory) {
|
|
33
|
+
try {
|
|
34
|
+
return await readdir(directory);
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
if (error.code === 'ENOENT')
|
|
38
|
+
return [];
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function firstLine(text) {
|
|
43
|
+
return text.split('\n')[0]?.trim() ?? '';
|
|
44
|
+
}
|
|
45
|
+
function failureMessage(error) {
|
|
46
|
+
return error instanceof Error ? error.message : String(error);
|
|
47
|
+
}
|
|
48
|
+
export async function openGitRepository(options) {
|
|
49
|
+
const { redact, listDir = listDirectoryOrEmpty, ...processOptions } = options;
|
|
50
|
+
const quickLimits = { timeoutMs: GIT_TIMEOUTS.quick, maxBytes: QUICK_OUTPUT_BYTES };
|
|
51
|
+
const failedWith = (result) => new Error(firstLine(redact(result.stderr)) || `git exited with code ${result.code}`);
|
|
52
|
+
let versionResult;
|
|
53
|
+
let detectResult;
|
|
54
|
+
try {
|
|
55
|
+
versionResult = await runGit(processOptions, GIT_COMMANDS.version, quickLimits);
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
if (error instanceof GitFailure && error.kind === 'not-found')
|
|
59
|
+
return { unavailable: 'git was not found on the PATH', quiet: false };
|
|
60
|
+
return { unavailable: error instanceof GitFailure ? error.message : redact(failureMessage(error)), quiet: false };
|
|
61
|
+
}
|
|
62
|
+
const version = parseGitVersion(versionResult.stdout.toString('utf8'));
|
|
63
|
+
if (!version) {
|
|
64
|
+
return { unavailable: `Could not read the git version from "${redact(firstLine(versionResult.stdout.toString('utf8')))}"`, quiet: false };
|
|
65
|
+
}
|
|
66
|
+
const versionText = version.join('.');
|
|
67
|
+
if (isBelow(version, MINIMUM_VERSION)) {
|
|
68
|
+
return { unavailable: `git 2.24 or later is required (found ${versionText})`, quiet: false };
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
detectResult = await runGit(processOptions, GIT_COMMANDS.detect, quickLimits);
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
return { unavailable: error instanceof GitFailure ? error.message : redact(failureMessage(error)), quiet: false };
|
|
75
|
+
}
|
|
76
|
+
if (detectResult.code !== 0) {
|
|
77
|
+
if (detectResult.stderr.includes(NOT_A_REPOSITORY))
|
|
78
|
+
return { unavailable: 'This directory is not in a git repository', quiet: true };
|
|
79
|
+
return { unavailable: firstLine(redact(detectResult.stderr)) || `git exited with code ${detectResult.code}`, quiet: false };
|
|
80
|
+
}
|
|
81
|
+
const lines = detectResult.stdout.toString('utf8').split('\n');
|
|
82
|
+
const prefix = lines[1] ?? '';
|
|
83
|
+
const commonDirectory = path.resolve(processOptions.cwd, lines[2] ?? '');
|
|
84
|
+
if (lines.length < DETECT_LINE_COUNT)
|
|
85
|
+
return { unavailable: 'git printed an unexpected repository description', quiet: false };
|
|
86
|
+
if (isBelow(version, LAZY_FETCH_VERSION)) {
|
|
87
|
+
const packFiles = await listDir(path.join(commonDirectory, 'objects', 'pack'));
|
|
88
|
+
if (packFiles.some((name) => name.endsWith(PROMISOR_SUFFIX))) {
|
|
89
|
+
return {
|
|
90
|
+
unavailable: `This is a partial clone, and git ${versionText} could download missing objects while reading. Use git 2.44 or later.`,
|
|
91
|
+
quiet: false,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const blobReader = createBlobReader(() => startGit(processOptions, GIT_COMMANDS.blobs), {
|
|
96
|
+
maxBlobBytes: GIT_LIMITS.blobBytes,
|
|
97
|
+
responseTimeoutMs: GIT_TIMEOUTS.blob,
|
|
98
|
+
});
|
|
99
|
+
return {
|
|
100
|
+
prefix,
|
|
101
|
+
async head() {
|
|
102
|
+
const symbolic = await runGit(processOptions, GIT_COMMANDS.symbolicHead, quickLimits);
|
|
103
|
+
const commit = await runGit(processOptions, GIT_COMMANDS.headCommit, quickLimits);
|
|
104
|
+
if (commit.code !== 0)
|
|
105
|
+
return undefined;
|
|
106
|
+
const head = { commit: commit.stdout.toString('utf8').trim() };
|
|
107
|
+
if (symbolic.code === 0)
|
|
108
|
+
head.branch = symbolic.stdout.toString('utf8').trim();
|
|
109
|
+
return head;
|
|
110
|
+
},
|
|
111
|
+
async refs() {
|
|
112
|
+
const result = await runGit(processOptions, GIT_COMMANDS.refs, { timeoutMs: GIT_TIMEOUTS.refs, maxBytes: GIT_LIMITS.outputBytes });
|
|
113
|
+
if (result.code !== 0)
|
|
114
|
+
throw failedWith(result);
|
|
115
|
+
return parseRefs(result.stdout.toString('utf8'));
|
|
116
|
+
},
|
|
117
|
+
async tree(commit) {
|
|
118
|
+
const args = treeArgs(commit);
|
|
119
|
+
const result = await runGit(processOptions, args, { timeoutMs: GIT_TIMEOUTS.slow, maxBytes: GIT_LIMITS.outputBytes });
|
|
120
|
+
if (result.code !== 0)
|
|
121
|
+
throw failedWith(result);
|
|
122
|
+
return parseTree(result.stdout, prefix, GIT_LIMITS.treeEntries);
|
|
123
|
+
},
|
|
124
|
+
async mergeBase(left, right) {
|
|
125
|
+
const args = mergeBaseArgs(left, right);
|
|
126
|
+
const result = await runGit(processOptions, args, { timeoutMs: GIT_TIMEOUTS.slow, maxBytes: QUICK_OUTPUT_BYTES });
|
|
127
|
+
if (result.code === 0)
|
|
128
|
+
return result.stdout.toString('utf8').trim();
|
|
129
|
+
if (result.code === NO_MERGE_BASE_EXIT_CODE)
|
|
130
|
+
return undefined;
|
|
131
|
+
throw new Error(firstLine(result.stderr) || `git exited with code ${result.code}`);
|
|
132
|
+
},
|
|
133
|
+
readBlob: (blobId) => blobReader.read(blobId),
|
|
134
|
+
close: () => blobReader.close(),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { isObjectId } from "./git-commands.js";
|
|
2
|
+
const PREFIXES = [
|
|
3
|
+
{ prefix: 'refs/heads/', kind: 'branch' },
|
|
4
|
+
{ prefix: 'refs/remotes/', kind: 'remote' },
|
|
5
|
+
{ prefix: 'refs/tags/', kind: 'tag' },
|
|
6
|
+
];
|
|
7
|
+
const KIND_ORDER = { branch: 0, remote: 1, tag: 2 };
|
|
8
|
+
const REMOTE_HEAD = /^refs\/remotes\/[^/]+\/HEAD$/;
|
|
9
|
+
function toFields(record) {
|
|
10
|
+
const parts = record.split('\0');
|
|
11
|
+
if (parts.length < 8)
|
|
12
|
+
return null;
|
|
13
|
+
const [name, objectType, objectId, peeledType, peeledId, committerSeconds, peeledSeconds, symref] = parts;
|
|
14
|
+
return { name, objectType, objectId, peeledType, peeledId, committerSeconds, peeledSeconds, symref };
|
|
15
|
+
}
|
|
16
|
+
function toIso(seconds) {
|
|
17
|
+
if (!/^\d+$/.test(seconds))
|
|
18
|
+
return null;
|
|
19
|
+
const date = new Date(Number(seconds) * 1000);
|
|
20
|
+
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
|
21
|
+
}
|
|
22
|
+
function toRef(fields) {
|
|
23
|
+
const matched = PREFIXES.find(({ prefix }) => fields.name.startsWith(prefix));
|
|
24
|
+
if (!matched)
|
|
25
|
+
return null;
|
|
26
|
+
const short = fields.name.slice(matched.prefix.length);
|
|
27
|
+
if (short.startsWith('-'))
|
|
28
|
+
return null;
|
|
29
|
+
let commit;
|
|
30
|
+
let seconds;
|
|
31
|
+
if (fields.objectType === 'commit') {
|
|
32
|
+
commit = fields.objectId;
|
|
33
|
+
seconds = fields.committerSeconds;
|
|
34
|
+
}
|
|
35
|
+
else if (fields.objectType === 'tag' && fields.peeledType === 'commit') {
|
|
36
|
+
commit = fields.peeledId;
|
|
37
|
+
seconds = fields.peeledSeconds;
|
|
38
|
+
}
|
|
39
|
+
else
|
|
40
|
+
return null;
|
|
41
|
+
if (!isObjectId(commit))
|
|
42
|
+
return null;
|
|
43
|
+
const committedAt = toIso(seconds);
|
|
44
|
+
if (committedAt === null)
|
|
45
|
+
return null;
|
|
46
|
+
return { name: fields.name, short, kind: matched.kind, commit, committedAt };
|
|
47
|
+
}
|
|
48
|
+
function compareRefs(left, right) {
|
|
49
|
+
if (left.kind !== right.kind)
|
|
50
|
+
return KIND_ORDER[left.kind] - KIND_ORDER[right.kind];
|
|
51
|
+
if (left.committedAt !== right.committedAt)
|
|
52
|
+
return left.committedAt < right.committedAt ? 1 : -1;
|
|
53
|
+
return left.name < right.name ? -1 : left.name > right.name ? 1 : 0;
|
|
54
|
+
}
|
|
55
|
+
export function parseRefs(output) {
|
|
56
|
+
const refs = [];
|
|
57
|
+
const remoteHeadTargets = [];
|
|
58
|
+
for (const record of output.split('\n')) {
|
|
59
|
+
if (record === '')
|
|
60
|
+
continue;
|
|
61
|
+
const fields = toFields(record);
|
|
62
|
+
if (!fields)
|
|
63
|
+
continue;
|
|
64
|
+
if (fields.symref !== '') {
|
|
65
|
+
if (REMOTE_HEAD.test(fields.name))
|
|
66
|
+
remoteHeadTargets.push(fields.symref);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const ref = toRef(fields);
|
|
70
|
+
if (ref)
|
|
71
|
+
refs.push(ref);
|
|
72
|
+
}
|
|
73
|
+
refs.sort(compareRefs);
|
|
74
|
+
const listed = new Set(refs.map((ref) => ref.name));
|
|
75
|
+
const defaultBranch = remoteHeadTargets.find((target) => listed.has(target)) ??
|
|
76
|
+
['refs/heads/main', 'refs/heads/master'].find((name) => listed.has(name));
|
|
77
|
+
return { refs, defaultBranch };
|
|
78
|
+
}
|