@mnemonik/shared 7.140.1 → 7.140.2
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/codeScanner.d.ts +41 -1
- package/dist/codeScanner.d.ts.map +1 -1
- package/dist/codeScanner.js +90 -12
- package/dist/codeScanner.js.map +1 -1
- package/dist/hookRuntime.d.ts +9 -8
- package/dist/hookRuntime.d.ts.map +1 -1
- package/dist/hookRuntime.js +21 -10
- package/dist/hookRuntime.js.map +1 -1
- package/dist/hookTrace.d.ts +149 -0
- package/dist/hookTrace.d.ts.map +1 -0
- package/dist/hookTrace.js +483 -0
- package/dist/hookTrace.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/projectIdentityFile.d.ts +13 -0
- package/dist/projectIdentityFile.d.ts.map +1 -1
- package/dist/projectIdentityFile.js +31 -18
- package/dist/projectIdentityFile.js.map +1 -1
- package/dist/protectedPaths.d.ts +6 -0
- package/dist/protectedPaths.d.ts.map +1 -1
- package/dist/protectedPaths.js +15 -6
- package/dist/protectedPaths.js.map +1 -1
- package/dist/repositoryRoot.d.ts +20 -8
- package/dist/repositoryRoot.d.ts.map +1 -1
- package/dist/repositoryRoot.js +167 -79
- package/dist/repositoryRoot.js.map +1 -1
- package/package.json +2 -2
- package/src/codeScanner.ts +107 -9
- package/src/hookRuntime.ts +26 -14
- package/src/hookTrace.ts +588 -0
- package/src/index.ts +4 -0
- package/src/projectIdentityFile.ts +34 -20
- package/src/protectedPaths.ts +18 -7
- package/src/repositoryRoot.ts +188 -82
- package/test-fixtures/identity/runner.d.mts +1 -1
- package/test-fixtures/identity/runner.mjs +299 -25
- package/tests/hookTrace.test.ts +350 -0
- package/tests/nearestProjectIdentity.test.ts +253 -0
package/src/repositoryRoot.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { lstat, readFile } from 'node:fs/promises';
|
|
1
|
+
import { lstat, readFile, realpath } from 'node:fs/promises';
|
|
2
2
|
import { execFile } from 'node:child_process';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { dirname, join, resolve } from 'node:path';
|
|
@@ -33,12 +33,6 @@ export type ProjectIdentityResolution =
|
|
|
33
33
|
| (ResolvedBase & { kind: 'absent' })
|
|
34
34
|
| (ResolvedBase & { kind: 'unknown_version'; version: unknown; path: string })
|
|
35
35
|
| (ResolvedBase & { kind: 'malformed'; detail: string; path: string })
|
|
36
|
-
| (ResolvedBase & { kind: 'nested'; parentIdentity?: ProjectIdentityFile })
|
|
37
|
-
| (ResolvedBase & {
|
|
38
|
-
kind: 'conflict';
|
|
39
|
-
rootIdentity: ProjectIdentityFile;
|
|
40
|
-
nestedIdentity: ProjectIdentityFile;
|
|
41
|
-
})
|
|
42
36
|
| { kind: 'git_unavailable'; detail: string };
|
|
43
37
|
|
|
44
38
|
const GIT_TIMEOUT_MS = 2_000;
|
|
@@ -102,7 +96,7 @@ async function confirmedBoundaryAt(path: string): Promise<RepositoryBoundary | u
|
|
|
102
96
|
async function boundariesBetween(cwd: string, root: string): Promise<RepositoryBoundary[]> {
|
|
103
97
|
const boundaries: RepositoryBoundary[] = [];
|
|
104
98
|
let directory = cwd;
|
|
105
|
-
while (directory
|
|
99
|
+
while (!samePath(directory, root)) {
|
|
106
100
|
const boundary = await confirmedBoundaryAt(directory);
|
|
107
101
|
if (boundary) boundaries.push(boundary);
|
|
108
102
|
const parent = dirname(directory);
|
|
@@ -123,37 +117,45 @@ async function containingRepository(root: string): Promise<RepositoryBoundary |
|
|
|
123
117
|
}
|
|
124
118
|
}
|
|
125
119
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
throw error;
|
|
139
|
-
}
|
|
120
|
+
/**
|
|
121
|
+
* A path as the file system knows it, symlinks resolved, so it compares equal
|
|
122
|
+
* with the real paths of git's answers. A path that does not exist stays as given.
|
|
123
|
+
*/
|
|
124
|
+
async function canonicalPath(path: string): Promise<string> {
|
|
125
|
+
const absolute = resolve(path);
|
|
126
|
+
return realpath(absolute).catch(() => absolute);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Path equality; Windows paths compare without regard to case. */
|
|
130
|
+
function samePath(left: string, right: string): boolean {
|
|
131
|
+
return process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right;
|
|
140
132
|
}
|
|
141
133
|
|
|
142
134
|
export async function resolveRepositoryRoot(cwd: string): Promise<RepositoryRootResult> {
|
|
143
|
-
const absoluteCwd =
|
|
135
|
+
const absoluteCwd = await canonicalPath(cwd);
|
|
144
136
|
try {
|
|
145
137
|
const { stdout } = await runGit(absoluteCwd, [
|
|
146
138
|
'rev-parse',
|
|
147
139
|
'--show-toplevel',
|
|
140
|
+
'--git-dir',
|
|
148
141
|
'--git-common-dir',
|
|
149
142
|
]);
|
|
150
|
-
const [rootLine, commonLine, ...extra] = stdout.trim().split(/\r?\n/);
|
|
151
|
-
if (!rootLine || !commonLine || extra.length) {
|
|
143
|
+
const [rootLine, gitDirLine, commonLine, ...extra] = stdout.trim().split(/\r?\n/);
|
|
144
|
+
if (!rootLine || !gitDirLine || !commonLine || extra.length) {
|
|
152
145
|
return { kind: 'git_unavailable', detail: 'git rev-parse returned an unexpected response' };
|
|
153
146
|
}
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
147
|
+
// git prints --git-dir and --git-common-dir relative to the directory it
|
|
148
|
+
// ran in (`../.git` from a subfolder) or absolute; every git version
|
|
149
|
+
// agrees on that. Real paths make them comparable with each other and
|
|
150
|
+
// with real paths from the file system.
|
|
151
|
+
const fromGit = (line: string) => canonicalPath(resolve(absoluteCwd, line));
|
|
152
|
+
const root = await fromGit(rootLine);
|
|
153
|
+
const gitDir = await fromGit(gitDirLine);
|
|
154
|
+
const commonDir = await fromGit(commonLine);
|
|
155
|
+
// Only a linked worktree keeps its own git directory apart from the common
|
|
156
|
+
// one. A submodule (`.git/modules/<name>`) and a `--separate-git-dir`
|
|
157
|
+
// checkout keep both in one place, wherever that is.
|
|
158
|
+
const isLinkedWorktree = !samePath(gitDir, commonDir);
|
|
157
159
|
const nested = await boundariesBetween(absoluteCwd, root);
|
|
158
160
|
if (!isLinkedWorktree && (await containingRepository(root))) {
|
|
159
161
|
const boundary = await boundaryAt(root);
|
|
@@ -175,16 +177,56 @@ export async function resolveRepositoryRoot(cwd: string): Promise<RepositoryRoot
|
|
|
175
177
|
}
|
|
176
178
|
}
|
|
177
179
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
180
|
+
/**
|
|
181
|
+
* The common git directory when `dir` is the top of a linked worktree: its
|
|
182
|
+
* `.git` is a file pointing at a git directory that has a `commondir`. A
|
|
183
|
+
* submodule's `.git` file points at `.git/modules/<name>`, which has none.
|
|
184
|
+
* Read from the files git writes, so it holds without a git command.
|
|
185
|
+
*/
|
|
186
|
+
async function linkedWorktreeCommonDir(dir: string): Promise<string | undefined> {
|
|
187
|
+
try {
|
|
188
|
+
if (!(await lstat(join(dir, '.git'))).isFile()) return undefined;
|
|
189
|
+
const pointer = /^gitdir:\s*(.+?)\s*$/im.exec(await readFile(join(dir, '.git'), 'utf8'))?.[1];
|
|
190
|
+
if (!pointer) return undefined;
|
|
191
|
+
const gitDir = resolve(dir, pointer);
|
|
192
|
+
const common = (await readFile(join(gitDir, 'commondir'), 'utf8')).trim();
|
|
193
|
+
return common ? await canonicalPath(resolve(gitDir, common)) : undefined;
|
|
194
|
+
} catch {
|
|
195
|
+
return undefined;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* The project folder of a linked worktree's main checkout: the first entry of
|
|
201
|
+
* `git worktree list --porcelain`. When that entry is a bare repository
|
|
202
|
+
* (`proj/.bare` with worktrees beside it), the project folder is the folder
|
|
203
|
+
* holding it, never the git directory itself. Without git, the folder holding
|
|
204
|
+
* the common git directory, which is the same answer for both layouts.
|
|
205
|
+
*/
|
|
206
|
+
async function mainCheckout(worktreeTop: string, commonDir: string): Promise<string> {
|
|
207
|
+
try {
|
|
208
|
+
const { stdout } = await runGit(worktreeTop, ['worktree', 'list', '--porcelain']);
|
|
209
|
+
const first = stdout.split(/\r?\n\r?\n/)[0] ?? '';
|
|
210
|
+
const path = /^worktree (.+)$/m.exec(first)?.[1];
|
|
211
|
+
if (path) {
|
|
212
|
+
const main = await canonicalPath(resolve(worktreeTop, path));
|
|
213
|
+
return /^bare$/m.test(first) ? dirname(main) : main;
|
|
214
|
+
}
|
|
215
|
+
} catch {
|
|
216
|
+
/* fall through */
|
|
217
|
+
}
|
|
218
|
+
return dirname(commonDir);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
type FoundIdentity = { root: string; result: Awaited<ReturnType<typeof readIdentityFile>> };
|
|
222
|
+
|
|
223
|
+
async function readPlainIdentity(start: string): Promise<FoundIdentity> {
|
|
224
|
+
const home = await canonicalPath(homedir());
|
|
183
225
|
let directory = start;
|
|
184
226
|
while (true) {
|
|
185
|
-
const result = await readIdentityFile(directory);
|
|
227
|
+
const result = await readIdentityFile(directory, { configurationOnlyAbsent: true });
|
|
186
228
|
if (result.kind !== 'absent') return { root: directory, result };
|
|
187
|
-
if (directory
|
|
229
|
+
if (samePath(directory, home)) break;
|
|
188
230
|
const parent = dirname(directory);
|
|
189
231
|
if (parent === directory) break;
|
|
190
232
|
directory = parent;
|
|
@@ -192,6 +234,105 @@ async function readPlainIdentity(start: string): Promise<{
|
|
|
192
234
|
return { root: start, result: { kind: 'absent' } };
|
|
193
235
|
}
|
|
194
236
|
|
|
237
|
+
/**
|
|
238
|
+
* Walk up from `start` to the nearest identity file, stopping at the home
|
|
239
|
+
* folder (whose own file still counts). A configuration-only file (no
|
|
240
|
+
* `projectId`) is not an identity and the walk passes it. Reaching the top of a linked worktree
|
|
241
|
+
* first ends the walk there: the worktree belongs to its main checkout, and
|
|
242
|
+
* the copy of the identity file at its top does not count.
|
|
243
|
+
*/
|
|
244
|
+
async function nearestIdentity(
|
|
245
|
+
start: string
|
|
246
|
+
): Promise<FoundIdentity | { worktreeTop: string; commonDir: string } | undefined> {
|
|
247
|
+
const home = await canonicalPath(homedir());
|
|
248
|
+
let directory = start;
|
|
249
|
+
while (true) {
|
|
250
|
+
const commonDir = await linkedWorktreeCommonDir(directory);
|
|
251
|
+
if (commonDir) return { worktreeTop: directory, commonDir };
|
|
252
|
+
const result = await readIdentityFile(directory, { configurationOnlyAbsent: true });
|
|
253
|
+
if (result.kind !== 'absent') return { root: directory, result };
|
|
254
|
+
if (samePath(directory, home)) return undefined;
|
|
255
|
+
const parent = dirname(directory);
|
|
256
|
+
if (parent === directory) return undefined;
|
|
257
|
+
directory = parent;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* The project a working folder belongs to: the nearest `.mnemonik.json` above
|
|
263
|
+
* it. Git repositories are not project boundaries; a clone, submodule,
|
|
264
|
+
* subsystem or subproject inside a project belongs to that project unless it
|
|
265
|
+
* has its own identity file. Git has one part in identity: a linked worktree
|
|
266
|
+
* (and anything inside it without an identity file of its own below the
|
|
267
|
+
* worktree's top, nested clones and submodules included) belongs to the
|
|
268
|
+
* project of its main checkout; the worktree's own top-level copy of the
|
|
269
|
+
* identity file does not count.
|
|
270
|
+
*/
|
|
271
|
+
export async function resolveNearestProjectIdentity(
|
|
272
|
+
cwd: string
|
|
273
|
+
): Promise<ProjectIdentityResolution> {
|
|
274
|
+
const start = await canonicalPath(cwd);
|
|
275
|
+
const resolved = await resolveRepositoryRoot(start);
|
|
276
|
+
const repository: ResolvedRepository =
|
|
277
|
+
resolved.kind === 'git_unavailable' ? { kind: 'plain', root: start } : resolved;
|
|
278
|
+
const nested = repository.kind === 'git' ? repository.nested : [];
|
|
279
|
+
|
|
280
|
+
let found = await nearestIdentity(start);
|
|
281
|
+
if (found && 'worktreeTop' in found) {
|
|
282
|
+
found = await readPlainIdentity(await mainCheckout(found.worktreeTop, found.commonDir));
|
|
283
|
+
}
|
|
284
|
+
if (found) {
|
|
285
|
+
return shapedIdentity(found, await projectFolderRepository(found.root, repository), nested);
|
|
286
|
+
}
|
|
287
|
+
return { ...withBase(repository, repository.root, nested), kind: 'absent' };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* The identity of exactly the folder named, for commands that create or link
|
|
292
|
+
* a project there on purpose, even inside another project. The top of a
|
|
293
|
+
* linked worktree stands for its main checkout.
|
|
294
|
+
*/
|
|
295
|
+
async function resolveNamedProjectIdentity(
|
|
296
|
+
cwd: string,
|
|
297
|
+
options: { selectedRoot?: boolean }
|
|
298
|
+
): Promise<ProjectIdentityResolution> {
|
|
299
|
+
const start = await canonicalPath(cwd);
|
|
300
|
+
const resolved = await resolveRepositoryRoot(start);
|
|
301
|
+
const repository: ResolvedRepository =
|
|
302
|
+
resolved.kind === 'git_unavailable' ? { kind: 'plain', root: start } : resolved;
|
|
303
|
+
const commonDir = await linkedWorktreeCommonDir(start);
|
|
304
|
+
const root = commonDir ? await mainCheckout(start, commonDir) : start;
|
|
305
|
+
return shapedIdentity(
|
|
306
|
+
{ root, result: await readIdentityFile(root, options) },
|
|
307
|
+
await projectFolderRepository(root, repository),
|
|
308
|
+
repository.kind === 'git' ? repository.nested : []
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function shapedIdentity(
|
|
313
|
+
found: FoundIdentity,
|
|
314
|
+
repository: ResolvedRepository,
|
|
315
|
+
nested: RepositoryBoundary[]
|
|
316
|
+
): ProjectIdentityResolution {
|
|
317
|
+
const base = withBase(repository, found.root, nested);
|
|
318
|
+
if (found.result.kind === 'ok') return { ...base, ...found.result };
|
|
319
|
+
if (found.result.kind === 'unknown_version')
|
|
320
|
+
return { ...base, ...found.result, path: found.root };
|
|
321
|
+
if (found.result.kind === 'malformed') return { ...base, ...found.result, path: found.root };
|
|
322
|
+
return { ...base, kind: 'absent' };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** The project folder counts as a git repository only when it is the repository's top. */
|
|
326
|
+
async function projectFolderRepository(
|
|
327
|
+
projectRoot: string,
|
|
328
|
+
known: ResolvedRepository
|
|
329
|
+
): Promise<ResolvedRepository> {
|
|
330
|
+
if (known.kind === 'git' && samePath(known.root, projectRoot)) return known;
|
|
331
|
+
const repository = await resolveRepositoryRoot(projectRoot);
|
|
332
|
+
if (repository.kind === 'git' && samePath(repository.root, projectRoot)) return repository;
|
|
333
|
+
return { kind: 'plain', root: projectRoot };
|
|
334
|
+
}
|
|
335
|
+
|
|
195
336
|
function withBase(
|
|
196
337
|
repository: ResolvedRepository,
|
|
197
338
|
root: string,
|
|
@@ -200,55 +341,20 @@ function withBase(
|
|
|
200
341
|
return { root, repository, nested };
|
|
201
342
|
}
|
|
202
343
|
|
|
344
|
+
/**
|
|
345
|
+
* Which project a folder is in. By default this is the rule every reader
|
|
346
|
+
* shares with the hooks (resolveNearestProjectIdentity). `namedRoot` and
|
|
347
|
+
* `selectedRoot` read exactly the folder given instead, for commands that make
|
|
348
|
+
* that folder a project on purpose; `selectedRoot` (folders ticked during
|
|
349
|
+
* install) also treats a configuration-only file as no identity, as the
|
|
350
|
+
* nearest-file walk always does.
|
|
351
|
+
*/
|
|
203
352
|
export async function resolveProjectIdentity(
|
|
204
353
|
cwd: string,
|
|
205
|
-
options: {
|
|
354
|
+
options: { selectedRoot?: boolean; namedRoot?: boolean } = {}
|
|
206
355
|
): Promise<ProjectIdentityResolution> {
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
const repository: ResolvedRepository =
|
|
210
|
-
resolved.kind === 'git_unavailable' ? { kind: 'plain', root: resolve(cwd) } : resolved;
|
|
211
|
-
|
|
212
|
-
if (repository.kind === 'plain') {
|
|
213
|
-
const { root, result } = options.selectedRoot
|
|
214
|
-
? { root: repository.root, result: await readIdentityFile(repository.root, options) }
|
|
215
|
-
: await readPlainIdentity(repository.root);
|
|
216
|
-
const base = withBase(repository, root, []);
|
|
217
|
-
if (result.kind === 'ok') return { ...base, ...result };
|
|
218
|
-
if (result.kind === 'unknown_version') return { ...base, ...result, path: root };
|
|
219
|
-
if (result.kind === 'malformed') return { ...base, ...result, path: root };
|
|
220
|
-
return { ...base, kind: 'absent' };
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
const ownRoot = repository.isLinkedWorktree ? dirname(repository.commonDir) : repository.root;
|
|
224
|
-
const outer = repository.isLinkedWorktree
|
|
225
|
-
? undefined
|
|
226
|
-
: await containingRepository(repository.root);
|
|
227
|
-
const own = await readIdentityFile(ownRoot, options);
|
|
228
|
-
if (!outer || options.selectedRoot || own.kind === 'ok') {
|
|
229
|
-
const base = withBase(repository, ownRoot, repository.nested);
|
|
230
|
-
if (own.kind === 'ok') return { ...base, ...own };
|
|
231
|
-
if (own.kind === 'unknown_version') return { ...base, ...own, path: ownRoot };
|
|
232
|
-
if (own.kind === 'malformed') return { ...base, ...own, path: ownRoot };
|
|
233
|
-
return { ...base, kind: 'absent' };
|
|
356
|
+
if (options.selectedRoot || options.namedRoot) {
|
|
357
|
+
return resolveNamedProjectIdentity(cwd, { selectedRoot: options.selectedRoot });
|
|
234
358
|
}
|
|
235
|
-
|
|
236
|
-
const nested = repository.nested;
|
|
237
|
-
const parentRoot = await canonicalBoundaryRoot(outer);
|
|
238
|
-
const parent = await readIdentityFile(parentRoot);
|
|
239
|
-
const base = withBase(repository, parentRoot, nested);
|
|
240
|
-
|
|
241
|
-
if (own.kind === 'unknown_version') return { ...base, ...own, path: ownRoot };
|
|
242
|
-
if (own.kind === 'malformed') return { ...base, ...own, path: ownRoot };
|
|
243
|
-
if (parent.kind === 'unknown_version') return { ...base, ...parent, path: parentRoot };
|
|
244
|
-
if (parent.kind === 'malformed') return { ...base, ...parent, path: parentRoot };
|
|
245
|
-
if (!options.allowNestedInherit) {
|
|
246
|
-
return {
|
|
247
|
-
...base,
|
|
248
|
-
kind: 'nested',
|
|
249
|
-
...(parent.kind === 'ok' ? { parentIdentity: parent.identity } : {}),
|
|
250
|
-
};
|
|
251
|
-
}
|
|
252
|
-
if (parent.kind === 'ok') return { ...base, kind: 'ok', identity: parent.identity };
|
|
253
|
-
return { ...base, kind: 'absent' };
|
|
359
|
+
return resolveNearestProjectIdentity(cwd);
|
|
254
360
|
}
|
|
@@ -2,7 +2,7 @@ export interface IdentityFixtureAdapter {
|
|
|
2
2
|
parse(text: string): unknown;
|
|
3
3
|
read(dir: string): Promise<unknown>;
|
|
4
4
|
resolveRoot(cwd: string): Promise<unknown>;
|
|
5
|
-
resolve(cwd: string, options?: {
|
|
5
|
+
resolve(cwd: string, options?: { namedRoot?: boolean }): Promise<unknown>;
|
|
6
6
|
find?(cwd: string): Promise<unknown>;
|
|
7
7
|
}
|
|
8
8
|
|