mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-17 01:21:10 -07:00
322 lines
9.5 KiB
TypeScript
322 lines
9.5 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import * as fs from 'node:fs';
|
|
import * as path from 'node:path';
|
|
import * as os from 'node:os';
|
|
import { lock } from 'proper-lockfile';
|
|
import { debugLogger } from '../utils/debugLogger.js';
|
|
|
|
export interface RegistryData {
|
|
projects: Record<string, string>;
|
|
}
|
|
|
|
const PROJECT_ROOT_FILE = '.project_root';
|
|
const LOCK_TIMEOUT_MS = 10000;
|
|
const LOCK_RETRY_DELAY_MS = 100;
|
|
|
|
/**
|
|
* Manages a mapping between absolute project paths and short, human-readable identifiers.
|
|
* This helps reduce context bloat and makes temporary directories easier to work with.
|
|
*/
|
|
export class ProjectRegistry {
|
|
private readonly registryPath: string;
|
|
private readonly baseDirs: string[];
|
|
private data: RegistryData | undefined;
|
|
private initPromise: Promise<void> | undefined;
|
|
|
|
constructor(registryPath: string, baseDirs: string[] = []) {
|
|
this.registryPath = registryPath;
|
|
this.baseDirs = baseDirs;
|
|
}
|
|
|
|
/**
|
|
* Initializes the registry by loading data from disk.
|
|
*/
|
|
async initialize(): Promise<void> {
|
|
if (this.initPromise) {
|
|
return this.initPromise;
|
|
}
|
|
|
|
this.initPromise = (async () => {
|
|
if (this.data) {
|
|
return;
|
|
}
|
|
|
|
this.data = await this.loadData();
|
|
})();
|
|
|
|
return this.initPromise;
|
|
}
|
|
|
|
private async loadData(): Promise<RegistryData> {
|
|
if (!fs.existsSync(this.registryPath)) {
|
|
return { projects: {} };
|
|
}
|
|
|
|
try {
|
|
const content = await fs.promises.readFile(this.registryPath, 'utf8');
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
|
return JSON.parse(content);
|
|
} catch (e) {
|
|
debugLogger.debug('Failed to load registry: ', e);
|
|
// If the registry is corrupted, we'll start fresh to avoid blocking the CLI
|
|
return { projects: {} };
|
|
}
|
|
}
|
|
|
|
private normalizePath(projectPath: string): string {
|
|
let resolved = path.resolve(projectPath);
|
|
if (os.platform() === 'win32') {
|
|
resolved = resolved.toLowerCase();
|
|
}
|
|
return resolved;
|
|
}
|
|
|
|
private async save(data: RegistryData): Promise<void> {
|
|
const dir = path.dirname(this.registryPath);
|
|
if (!fs.existsSync(dir)) {
|
|
await fs.promises.mkdir(dir, { recursive: true });
|
|
}
|
|
|
|
try {
|
|
const content = JSON.stringify(data, null, 2);
|
|
const tmpPath = `${this.registryPath}.tmp`;
|
|
await fs.promises.writeFile(tmpPath, content, 'utf8');
|
|
await fs.promises.rename(tmpPath, this.registryPath);
|
|
} catch (error) {
|
|
debugLogger.error(
|
|
`Failed to save project registry to ${this.registryPath}:`,
|
|
error,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns a short identifier for the given project path.
|
|
* If the project is not already in the registry, a new identifier is generated and saved.
|
|
*/
|
|
async getShortId(projectPath: string): Promise<string> {
|
|
if (!this.data) {
|
|
throw new Error('ProjectRegistry must be initialized before use');
|
|
}
|
|
|
|
const normalizedPath = this.normalizePath(projectPath);
|
|
|
|
// Ensure directory exists so we can create a lock file
|
|
const dir = path.dirname(this.registryPath);
|
|
if (!fs.existsSync(dir)) {
|
|
await fs.promises.mkdir(dir, { recursive: true });
|
|
}
|
|
// Ensure the registry file exists so proper-lockfile can lock it
|
|
if (!fs.existsSync(this.registryPath)) {
|
|
await this.save({ projects: {} });
|
|
}
|
|
|
|
// Use proper-lockfile to prevent racy updates
|
|
const release = await lock(this.registryPath, {
|
|
retries: {
|
|
retries: Math.floor(LOCK_TIMEOUT_MS / LOCK_RETRY_DELAY_MS),
|
|
minTimeout: LOCK_RETRY_DELAY_MS,
|
|
},
|
|
});
|
|
|
|
try {
|
|
// Re-load data under lock to get the latest state
|
|
const currentData = await this.loadData();
|
|
this.data = currentData;
|
|
|
|
let shortId: string | undefined = currentData.projects[normalizedPath];
|
|
|
|
// If we have a mapping, verify it against the folders on disk
|
|
if (shortId) {
|
|
if (await this.verifySlugOwnership(shortId, normalizedPath)) {
|
|
// HEAL: If it passed verification but markers are missing (e.g. new base dir or deleted marker), recreate them.
|
|
await this.ensureOwnershipMarkers(shortId, normalizedPath);
|
|
return shortId;
|
|
}
|
|
// If verification fails, it means the registry is out of sync or someone else took it.
|
|
// We'll remove the mapping and find/generate a new one.
|
|
delete currentData.projects[normalizedPath];
|
|
}
|
|
|
|
// Try to find if this project already has folders assigned that we didn't know about
|
|
shortId = await this.findExistingSlugForPath(normalizedPath);
|
|
|
|
if (!shortId) {
|
|
// Generate a new one
|
|
shortId = await this.claimNewSlug(normalizedPath, currentData.projects);
|
|
}
|
|
|
|
currentData.projects[normalizedPath] = shortId;
|
|
await this.save(currentData);
|
|
return shortId;
|
|
} finally {
|
|
await release();
|
|
}
|
|
}
|
|
|
|
private async verifySlugOwnership(
|
|
slug: string,
|
|
projectPath: string,
|
|
): Promise<boolean> {
|
|
if (this.baseDirs.length === 0) {
|
|
return true; // Nothing to verify against
|
|
}
|
|
|
|
for (const baseDir of this.baseDirs) {
|
|
const markerPath = path.join(baseDir, slug, PROJECT_ROOT_FILE);
|
|
if (fs.existsSync(markerPath)) {
|
|
try {
|
|
const owner = (await fs.promises.readFile(markerPath, 'utf8')).trim();
|
|
if (this.normalizePath(owner) !== this.normalizePath(projectPath)) {
|
|
return false;
|
|
}
|
|
} catch (e) {
|
|
debugLogger.debug(
|
|
`Failed to read ownership marker ${markerPath}:`,
|
|
e,
|
|
);
|
|
// If we can't read it, assume it's not ours or corrupted.
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
private async findExistingSlugForPath(
|
|
projectPath: string,
|
|
): Promise<string | undefined> {
|
|
if (this.baseDirs.length === 0) {
|
|
return undefined;
|
|
}
|
|
|
|
const normalizedTarget = this.normalizePath(projectPath);
|
|
|
|
// Scan all base dirs to see if any slug already belongs to this project
|
|
for (const baseDir of this.baseDirs) {
|
|
if (!fs.existsSync(baseDir)) {
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
const candidates = await fs.promises.readdir(baseDir);
|
|
for (const candidate of candidates) {
|
|
const markerPath = path.join(baseDir, candidate, PROJECT_ROOT_FILE);
|
|
if (fs.existsSync(markerPath)) {
|
|
const owner = (
|
|
await fs.promises.readFile(markerPath, 'utf8')
|
|
).trim();
|
|
if (this.normalizePath(owner) === normalizedTarget) {
|
|
// Found it! Ensure all base dirs have the marker
|
|
await this.ensureOwnershipMarkers(candidate, normalizedTarget);
|
|
return candidate;
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
debugLogger.debug(`Failed to scan base dir ${baseDir}:`, e);
|
|
}
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
private async claimNewSlug(
|
|
projectPath: string,
|
|
existingMappings: Record<string, string>,
|
|
): Promise<string> {
|
|
const baseName = path.basename(projectPath) || 'project';
|
|
const slug = this.slugify(baseName);
|
|
|
|
let counter = 0;
|
|
const existingIds = new Set(Object.values(existingMappings));
|
|
|
|
while (true) {
|
|
const candidate = counter === 0 ? slug : `${slug}-${counter}`;
|
|
counter++;
|
|
|
|
// Check if taken in registry
|
|
if (existingIds.has(candidate)) {
|
|
continue;
|
|
}
|
|
|
|
// Check if taken on disk
|
|
let diskCollision = false;
|
|
for (const baseDir of this.baseDirs) {
|
|
const markerPath = path.join(baseDir, candidate, PROJECT_ROOT_FILE);
|
|
if (fs.existsSync(markerPath)) {
|
|
try {
|
|
const owner = (
|
|
await fs.promises.readFile(markerPath, 'utf8')
|
|
).trim();
|
|
if (this.normalizePath(owner) !== this.normalizePath(projectPath)) {
|
|
diskCollision = true;
|
|
break;
|
|
}
|
|
} catch (_e) {
|
|
// If we can't read it, assume it's someone else's to be safe
|
|
diskCollision = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (diskCollision) {
|
|
continue;
|
|
}
|
|
|
|
// Try to claim it
|
|
try {
|
|
await this.ensureOwnershipMarkers(candidate, projectPath);
|
|
return candidate;
|
|
} catch (_e) {
|
|
// Someone might have claimed it between our check and our write.
|
|
// Try next candidate.
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
|
|
private async ensureOwnershipMarkers(
|
|
slug: string,
|
|
projectPath: string,
|
|
): Promise<void> {
|
|
const normalizedProject = this.normalizePath(projectPath);
|
|
for (const baseDir of this.baseDirs) {
|
|
const slugDir = path.join(baseDir, slug);
|
|
if (!fs.existsSync(slugDir)) {
|
|
await fs.promises.mkdir(slugDir, { recursive: true });
|
|
}
|
|
const markerPath = path.join(slugDir, PROJECT_ROOT_FILE);
|
|
if (fs.existsSync(markerPath)) {
|
|
const owner = (await fs.promises.readFile(markerPath, 'utf8')).trim();
|
|
if (this.normalizePath(owner) === normalizedProject) {
|
|
continue;
|
|
}
|
|
// Collision!
|
|
throw new Error(`Slug ${slug} is already owned by ${owner}`);
|
|
}
|
|
// Use flag: 'wx' to ensure atomic creation
|
|
await fs.promises.writeFile(markerPath, normalizedProject, {
|
|
encoding: 'utf8',
|
|
flag: 'wx',
|
|
});
|
|
}
|
|
}
|
|
|
|
private slugify(text: string): string {
|
|
return (
|
|
text
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]/g, '-')
|
|
.replace(/-+/g, '-')
|
|
.replace(/^-|-$/g, '') || 'project'
|
|
);
|
|
}
|
|
}
|