GitNexus/gitnexus-cursor-integration/hooks/registry-query.cjs
mengkaka 79543c8f83
feat(storage): add configurable index storage and content retention tiers (#3060)
* feat(storage): add configurable index storage and content retention tiers

Rebase #3060 onto current origin/main. Keep GITNEXUS_STORAGE_PATH,
GITNEXUS_STORAGE_ROOT, and GITNEXUS_CONTENT_RETENTION, and fold in
main's FTS skip, embed-session, and help-text updates.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3060)

Keep legacy registry rows on the local storage fallback, resolve
symlinks before the destructive-path guard, and align hook lookup
with CLI branch slugs, branch-slot metadata, and longest-path match.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3060)

Only list swept upload directories after a successful removal so
callers cannot treat a permission or transient rm failure as gone.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3060)

Document that getStoragePath may consult registered storage while
this module still does not mutate the global registry.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(storage): close review findings for external indexes and retention

Re-inspect ownership under the analyze lock, fail-closed when the
registry file is missing, and keep skip-git hook discovery plus
retention fields on HTTP/MCP list surfaces. /api/file stays 410
unless contentRetention is full.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(autofix): apply prettier + eslint fixes via /autofix command

* Address PR review feedback (#3060)

Treat lock-only index dirs as empty, honor HTTP --force storage policy, and prefer registered plus branch-aware slots in hooks and augment.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3060)

Keep hook fallbacks inside the current worktree, compare foreign-local slots canonically, and make storage fixtures survive ownership validation.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix macOS hook test expecting realpath'd registry paths.

resolveHookRepo returns the written registry path, not a filesystem realpath, so the assertion must match that.

* Address gitnexus-check warnings on hook install docs and slot tests.

The Cursor troubleshooting list omitted registry-query.cjs, and the writable-slot test only checked that isDirectory exists instead of that the path is a directory.

* Align the HTTP catalog source-scan with skippable resolveRepo validation.

resolveRepo lists fresh repos with validate: options.validateStorage !== false so DELETE can skip prune; the test still required a literal validate: true.

* Harden storage path sinks so CodeQL path-injection and ReDoS alerts clear.

Contain every filesystem probe inside the resolved storage slot with the inline path.relative idiom, reject filesystem-root slots, and trim slot basenames in linear time.

* Settle bridge stamps before writing so CI size/mtime matches stay stable.

LadybugDB can still flush into bridge.lbug after close+rename; persist whole-millisecond mtimes and wait for consecutive stats to agree so a freshly written pair matches.

* Type the settled bridge stat as fs.Stats so tsc does not see bigint.

Awaited<ReturnType<typeof fsp.stat>> collapsed the bigint overload and broke prepare/typecheck on CI.

* Keep the bridge mtime stamp exact so same-size swaps still fail the pair check.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Wrap the bridge stamp predicate so prettier --check stays green.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Require a quiet interval before stamping a settled bridge file.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Reuse shared storage and settle helpers instead of local copies.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-09-12 20:31:55 +00:00

410 lines
13 KiB
JavaScript

const fs = require('fs');
const os = require('os');
const path = require('path');
const { createHash } = require('crypto');
const { spawnSync } = require('child_process');
// Hooks are copied into editor-specific directories and run without the
// package's TypeScript modules. Keep their on-disk names centralized here.
const GITNEXUS_DIR = '.gitnexus';
const INDEX_METADATA_FILE = 'gitnexus.json';
const LEGACY_METADATA_FILE = 'meta.json';
const LBUG_DIRECTORY = 'lbug';
const BRANCHES_DIRECTORY = 'branches';
const STORAGE_PATH_ENV = 'GITNEXUS_STORAGE_PATH';
const STORAGE_ROOT_ENV = 'GITNEXUS_STORAGE_ROOT';
const STORAGE_SLOT_HASH_LENGTH = 12;
const LOCAL_OWNED_PARENT_HOPS = 5;
function stripWindowsLongPathPrefix(p) {
if (process.platform !== 'win32') return p;
if (/^\\\\\?\\UNC\\(?=[^\\])/i.test(p)) return `\\\\${p.slice(8)}`;
if (/^\\\\\?\\[A-Za-z]:\\/.test(p)) return p.slice(4);
return p;
}
function canonicalize(value) {
if (typeof value !== 'string' || !value || value.includes('\0') || !path.isAbsolute(value))
return null;
const resolved = path.resolve(value);
try {
return stripWindowsLongPathPrefix(fs.realpathSync.native(resolved));
} catch {
return stripWindowsLongPathPrefix(resolved);
}
}
function samePath(left, right) {
if (left == null || right == null) return false;
return process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right;
}
function isMissingFile(error) {
return error && (error.code === 'ENOENT' || error.code === 'ENOTDIR');
}
function readMetadataFile(storagePath, filename) {
try {
const value = JSON.parse(fs.readFileSync(path.join(storagePath, filename), 'utf-8'));
return value && typeof value === 'object' && !Array.isArray(value)
? { state: 'valid', value }
: { state: 'invalid' };
} catch (error) {
return isMissingFile(error) ? { state: 'absent' } : { state: 'invalid' };
}
}
function readIndexMetadata(storagePath) {
const primary = readMetadataFile(storagePath, INDEX_METADATA_FILE);
if (primary.state === 'valid') return primary.value;
if (primary.state !== 'absent') return null;
const legacy = readMetadataFile(storagePath, LEGACY_METADATA_FILE);
return legacy.state === 'valid' ? legacy.value : null;
}
function isOwnedStorage(repoPath, storagePath, repositoryLocal, metadata) {
// Repository-local storage remains usable for metadata written before
// repoPath was recorded, but an explicit repoPath must never name another
// checkout. External storage always requires the complete ownership binding.
if (repositoryLocal && (!metadata || typeof metadata.repoPath !== 'string')) {
return true;
}
if (!metadata || typeof metadata.repoPath !== 'string') return false;
const metadataRepoPath = canonicalize(metadata.repoPath);
const expectedRepoPath = canonicalize(repoPath);
if (
metadataRepoPath == null ||
expectedRepoPath == null ||
!samePath(metadataRepoPath, expectedRepoPath)
) {
return false;
}
if (repositoryLocal) return true;
if (typeof metadata.storagePath !== 'string') return false;
const metadataStoragePath = canonicalize(metadata.storagePath);
const expectedStoragePath = canonicalize(storagePath);
return (
metadataStoragePath != null &&
expectedStoragePath != null &&
samePath(metadataStoragePath, expectedStoragePath)
);
}
function ancestorPaths(cwd) {
const paths = [];
let current = canonicalize(cwd);
while (current) {
paths.push(current);
const parent = path.dirname(current);
if (parent === current) break;
current = parent;
}
return paths;
}
function isInsideOrEqual(child, ancestor) {
if (child == null || ancestor == null) return false;
if (samePath(child, ancestor)) return true;
const relative = path.relative(ancestor, child);
return (
relative !== '' &&
relative !== '..' &&
!relative.startsWith(`..${path.sep}`) &&
!path.isAbsolute(relative)
);
}
function ancestorPathsThrough(cwd, stopAt) {
const paths = [];
let current = canonicalize(cwd);
const stop = canonicalize(stopAt);
while (current) {
if (stop && !isInsideOrEqual(current, stop)) break;
paths.push(current);
if (stop && samePath(current, stop)) break;
const parent = path.dirname(current);
if (parent === current) break;
current = parent;
}
return paths;
}
function currentGitBranch(cwd) {
try {
const result = spawnSync('git', ['symbolic-ref', '--quiet', '--short', 'HEAD'], {
encoding: 'utf-8',
timeout: 2000,
cwd,
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true,
});
if (result.error || result.status !== 0) return null;
const branch = String(result.stdout || '').trim();
return branch || null;
} catch {
return null;
}
}
function registryPathsForCwd(cwd) {
const fallbackPaths = ancestorPaths(cwd);
if (fallbackPaths.length === 0) return { repoPaths: [], branch: null };
try {
const result = spawnSync(
'git',
['rev-parse', '--path-format=absolute', '--show-toplevel', '--git-common-dir'],
{
encoding: 'utf-8',
timeout: 2000,
cwd,
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true,
},
);
if (result.error || result.status !== 0) return { repoPaths: fallbackPaths, branch: null };
const [worktreeRoot, commonDir] = String(result.stdout || '')
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
if (!worktreeRoot || !path.isAbsolute(worktreeRoot)) {
return { repoPaths: fallbackPaths, branch: null };
}
// Keep ancestor paths of cwd that stay inside this worktree (cwd up to
// and including show-toplevel) so a --skip-git subdirectory index can
// win via longest-match. Do not walk ancestors outside the worktree —
// that would re-attribute a parent index to a nested git checkout.
const repoPaths = ancestorPathsThrough(cwd, worktreeRoot);
const worktreeCanon = canonicalize(worktreeRoot);
if (worktreeCanon && !repoPaths.some((repoPath) => samePath(repoPath, worktreeCanon))) {
repoPaths.push(worktreeCanon);
}
// Linked worktrees share the canonical repo's git dir. Include that
// parent so the registered main checkout is still discoverable, but do
// not walk any further outside this worktree.
if (commonDir) {
const commonParent = canonicalize(path.dirname(commonDir));
if (
commonParent &&
worktreeCanon &&
!samePath(commonParent, worktreeCanon) &&
!repoPaths.some((repoPath) => samePath(repoPath, commonParent))
) {
repoPaths.push(commonParent);
}
}
return {
repoPaths,
branch: currentGitBranch(cwd),
};
} catch {
return { repoPaths: fallbackPaths, branch: null };
}
}
function branchSlug(rawRef) {
const sanitized = rawRef.replace(/^-+/, '').replace(/[^a-zA-Z0-9._-]/g, '_');
const reserved = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\..*)?$/i;
const safe =
!sanitized || sanitized === '.' || sanitized === '..' || reserved.test(sanitized)
? 'unknown'
: sanitized;
const hash = createHash('sha256').update(rawRef).digest('hex').slice(0, 8);
return `${safe}-${hash}`;
}
// Mirror gitnexus/src/storage/storage-resolver.ts storageSlotName exactly
// (sanitize + sha256 of the canonical repo path, 12-hex suffix).
function sanitizeSlotBasename(value) {
// Cap first, then walk the tail once — same order as
// gitnexus/src/storage/storage-resolver.ts (avoids /[. ]+$/ ReDoS).
const sanitized = value.replace(/[\u0000-\u001f<>:"/\\|?*]/g, '-').slice(0, 80);
let end = sanitized.length;
while (end > 0) {
const code = sanitized.charCodeAt(end - 1);
if (code !== 0x20 && code !== 0x2e) break;
end--;
}
const candidate = sanitized.slice(0, end) || 'repository';
return /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(candidate)
? `repository-${candidate}`
: candidate;
}
function storageSlotName(repoPath) {
const canonical = canonicalize(repoPath);
if (!canonical) return null;
const identity = process.platform === 'win32' ? canonical.toLowerCase() : canonical;
const basename = sanitizeSlotBasename(path.basename(canonical));
const digest = createHash('sha256')
.update(identity)
.digest('hex')
.slice(0, STORAGE_SLOT_HASH_LENGTH);
return `${basename}-${digest}`;
}
function envOverridesStorage() {
const envPath = process.env[STORAGE_PATH_ENV];
const envRoot = process.env[STORAGE_ROOT_ENV];
return (
(typeof envPath === 'string' && envPath.length > 0) ||
(typeof envRoot === 'string' && envRoot.length > 0)
);
}
function resolveEntryStoragePath(entry) {
const envPath = process.env[STORAGE_PATH_ENV];
if (
typeof envPath === 'string' &&
envPath.length > 0 &&
!envPath.includes('\0') &&
path.isAbsolute(envPath)
) {
const resolved = path.resolve(envPath);
if (path.isAbsolute(resolved)) return resolved;
}
const envRoot = process.env[STORAGE_ROOT_ENV];
if (
typeof envRoot === 'string' &&
envRoot.length > 0 &&
!envRoot.includes('\0') &&
path.isAbsolute(envRoot)
) {
const root = path.resolve(envRoot);
const slot = storageSlotName(entry.path);
if (slot) {
const storagePath = path.join(root, slot);
if (samePath(path.dirname(storagePath), root)) return storagePath;
}
}
if (entry.storagePath !== undefined) {
if (
typeof entry.storagePath !== 'string' ||
!entry.storagePath ||
entry.storagePath.includes('\0') ||
!path.isAbsolute(entry.storagePath)
) {
return null;
}
return path.resolve(entry.storagePath);
}
return path.resolve(path.join(entry.path, GITNEXUS_DIR));
}
function hasLocalIndexSignal(storagePath) {
try {
return (
fs.existsSync(path.join(storagePath, INDEX_METADATA_FILE)) ||
fs.existsSync(path.join(storagePath, LBUG_DIRECTORY))
);
} catch {
return false;
}
}
function findLocalOwnedRepo(cwd) {
// Environment storage overrides win; a leftover repo-local .gitnexus must
// not skip the registry scan that applies STORAGE_PATH / STORAGE_ROOT.
if (envOverridesStorage()) return null;
const { repoPaths, branch } = registryPathsForCwd(cwd);
let current = canonicalize(cwd);
for (let hops = 0; hops <= LOCAL_OWNED_PARENT_HOPS && current; hops++) {
const storagePath = path.join(current, GITNEXUS_DIR);
if (hasLocalIndexSignal(storagePath)) {
const metadata = readIndexMetadata(storagePath);
if (isOwnedStorage(current, storagePath, true, metadata)) {
const branchDir =
branch != null ? path.join(storagePath, BRANCHES_DIRECTORY, branchSlug(branch)) : null;
const indexDir = branchDir && hasLocalIndexSignal(branchDir) ? branchDir : storagePath;
return {
path: current,
storagePath,
lbugPath: path.join(indexDir, LBUG_DIRECTORY),
metadata: indexDir === storagePath ? metadata : readIndexMetadata(indexDir),
};
}
}
const parent = path.dirname(current);
if (parent === current) break;
// Stay inside this checkout. Registered lookup already stops at
// `--show-toplevel`; walking raw parents would adopt `/outer/.gitnexus`
// from `/outer/nested-repo`.
if (repoPaths.length > 0 && !repoPaths.some((repoPath) => samePath(repoPath, parent))) {
break;
}
current = parent;
}
return null;
}
function findRegisteredRepo(cwd) {
const { repoPaths, branch } = registryPathsForCwd(cwd);
if (repoPaths.length === 0) return null;
const home = process.env.GITNEXUS_HOME || path.join(os.homedir(), '.gitnexus');
let entries;
try {
entries = JSON.parse(fs.readFileSync(path.join(home, 'registry.json'), 'utf-8'));
} catch {
return null;
}
if (!Array.isArray(entries)) return null;
let best = null;
let bestLen = -1;
for (const entry of entries) {
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
if (typeof entry.path !== 'string') continue;
if (entry.path.includes('\0') || !path.isAbsolute(entry.path)) continue;
const registeredPath = canonicalize(entry.path);
if (!registeredPath || !repoPaths.some((repoPath) => samePath(repoPath, registeredPath))) {
continue;
}
const storagePath = resolveEntryStoragePath(entry);
if (!storagePath) continue;
const repositoryLocal = samePath(
canonicalize(path.join(entry.path, GITNEXUS_DIR)),
canonicalize(storagePath),
);
const ownershipMetadata = readIndexMetadata(storagePath);
if (!isOwnedStorage(entry.path, storagePath, repositoryLocal, ownershipMetadata)) continue;
const branchIsIndexed =
branch &&
Array.isArray(entry.branches) &&
entry.branches.some((summary) => summary && summary.branch === branch);
const indexDir = branchIsIndexed
? path.join(storagePath, BRANCHES_DIRECTORY, branchSlug(branch))
: storagePath;
if (registeredPath.length > bestLen) {
bestLen = registeredPath.length;
best = {
path: entry.path,
storagePath,
lbugPath: path.join(indexDir, LBUG_DIRECTORY),
metadata: branchIsIndexed ? readIndexMetadata(indexDir) : ownershipMetadata,
};
}
}
return best;
}
/** Registry row wins (including persisted external storagePath); local owned is fallback. */
function resolveHookRepo(cwd) {
return findRegisteredRepo(cwd) || findLocalOwnedRepo(cwd);
}
module.exports = {
findRegisteredRepo,
findLocalOwnedRepo,
resolveHookRepo,
INDEX_METADATA_FILE,
LEGACY_METADATA_FILE,
LBUG_DIRECTORY,
};