mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-10 22:21:22 -07:00
400 lines
11 KiB
TypeScript
400 lines
11 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import path from 'node:path';
|
|
import os from 'node:os';
|
|
import * as crypto from 'node:crypto';
|
|
import * as fs from 'node:fs';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
export const GEMINI_DIR = '.gemini';
|
|
export const GOOGLE_ACCOUNTS_FILENAME = 'google_accounts.json';
|
|
|
|
/**
|
|
* Returns the home directory.
|
|
* If GEMINI_CLI_HOME environment variable is set, it returns its value.
|
|
* Otherwise, it returns the user's home directory.
|
|
*/
|
|
export function homedir(): string {
|
|
const envHome = process.env['GEMINI_CLI_HOME'];
|
|
if (envHome) {
|
|
return envHome;
|
|
}
|
|
return os.homedir();
|
|
}
|
|
|
|
/**
|
|
* Returns the operating system's default directory for temporary files.
|
|
*/
|
|
export function tmpdir(): string {
|
|
return os.tmpdir();
|
|
}
|
|
|
|
/**
|
|
* Replaces the home directory with a tilde.
|
|
* @param path - The path to tildeify.
|
|
* @returns The tildeified path.
|
|
*/
|
|
export function tildeifyPath(path: string): string {
|
|
const homeDir = homedir();
|
|
if (path.startsWith(homeDir)) {
|
|
return path.replace(homeDir, '~');
|
|
}
|
|
return path;
|
|
}
|
|
|
|
/**
|
|
* Shortens a path string if it exceeds maxLen, prioritizing the start and end segments.
|
|
* Example: /path/to/a/very/long/file.txt -> /path/.../long/file.txt
|
|
*/
|
|
export function shortenPath(filePath: string, maxLen: number = 35): string {
|
|
if (filePath.length <= maxLen) {
|
|
return filePath;
|
|
}
|
|
|
|
const simpleTruncate = () => {
|
|
const keepLen = Math.floor((maxLen - 3) / 2);
|
|
if (keepLen <= 0) {
|
|
return filePath.substring(0, maxLen - 3) + '...';
|
|
}
|
|
const start = filePath.substring(0, keepLen);
|
|
const end = filePath.substring(filePath.length - keepLen);
|
|
return `${start}...${end}`;
|
|
};
|
|
|
|
type TruncateMode = 'start' | 'end' | 'center';
|
|
|
|
const truncateComponent = (
|
|
component: string,
|
|
targetLength: number,
|
|
mode: TruncateMode,
|
|
): string => {
|
|
if (component.length <= targetLength) {
|
|
return component;
|
|
}
|
|
|
|
if (targetLength <= 0) {
|
|
return '';
|
|
}
|
|
|
|
if (targetLength <= 3) {
|
|
if (mode === 'end') {
|
|
return component.slice(-targetLength);
|
|
}
|
|
return component.slice(0, targetLength);
|
|
}
|
|
|
|
if (mode === 'start') {
|
|
return `${component.slice(0, targetLength - 3)}...`;
|
|
}
|
|
|
|
if (mode === 'end') {
|
|
return `...${component.slice(component.length - (targetLength - 3))}`;
|
|
}
|
|
|
|
const front = Math.ceil((targetLength - 3) / 2);
|
|
const back = targetLength - 3 - front;
|
|
return `${component.slice(0, front)}...${component.slice(
|
|
component.length - back,
|
|
)}`;
|
|
};
|
|
|
|
const parsedPath = path.parse(filePath);
|
|
const root = parsedPath.root;
|
|
const separator = path.sep;
|
|
|
|
// Get segments of the path *after* the root
|
|
const relativePath = filePath.substring(root.length);
|
|
const segments = relativePath.split(separator).filter((s) => s !== ''); // Filter out empty segments
|
|
|
|
// Handle cases with no segments after root (e.g., "/", "C:\") or only one segment
|
|
if (segments.length <= 1) {
|
|
// Fall back to simple start/end truncation for very short paths or single segments
|
|
return simpleTruncate();
|
|
}
|
|
|
|
const firstDir = segments[0];
|
|
const lastSegment = segments[segments.length - 1];
|
|
const startComponent = root + firstDir;
|
|
|
|
const endPartSegments = [lastSegment];
|
|
let endPartLength = lastSegment.length;
|
|
|
|
// Iterate backwards through the middle segments
|
|
for (let i = segments.length - 2; i > 0; i--) {
|
|
const segment = segments[i];
|
|
const newLength =
|
|
startComponent.length +
|
|
separator.length +
|
|
3 + // for "..."
|
|
separator.length +
|
|
endPartLength +
|
|
separator.length +
|
|
segment.length;
|
|
|
|
if (newLength <= maxLen) {
|
|
endPartSegments.unshift(segment);
|
|
endPartLength += separator.length + segment.length;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
|
|
const components = [firstDir, ...endPartSegments];
|
|
const componentModes: TruncateMode[] = components.map((_, index) => {
|
|
if (index === 0) {
|
|
return 'start';
|
|
}
|
|
if (index === components.length - 1) {
|
|
return 'end';
|
|
}
|
|
return 'center';
|
|
});
|
|
|
|
const separatorsCount = endPartSegments.length + 1;
|
|
const fixedLen = root.length + separatorsCount * separator.length + 3; // ellipsis length
|
|
const availableForComponents = maxLen - fixedLen;
|
|
|
|
const trailingFallback = () => {
|
|
const ellipsisTail = `...${separator}${lastSegment}`;
|
|
if (ellipsisTail.length <= maxLen) {
|
|
return ellipsisTail;
|
|
}
|
|
|
|
if (root) {
|
|
const rootEllipsisTail = `${root}...${separator}${lastSegment}`;
|
|
if (rootEllipsisTail.length <= maxLen) {
|
|
return rootEllipsisTail;
|
|
}
|
|
}
|
|
|
|
if (root && `${root}${lastSegment}`.length <= maxLen) {
|
|
return `${root}${lastSegment}`;
|
|
}
|
|
|
|
if (lastSegment.length <= maxLen) {
|
|
return lastSegment;
|
|
}
|
|
|
|
// As a final resort (e.g., last segment itself exceeds maxLen), fall back to simple truncation.
|
|
return simpleTruncate();
|
|
};
|
|
|
|
if (availableForComponents <= 0) {
|
|
return trailingFallback();
|
|
}
|
|
|
|
const minLengths = components.map((component, index) => {
|
|
if (index === 0) {
|
|
return Math.min(component.length, 1);
|
|
}
|
|
if (index === components.length - 1) {
|
|
return component.length; // Never truncate the last segment when possible.
|
|
}
|
|
return Math.min(component.length, 1);
|
|
});
|
|
|
|
const minTotal = minLengths.reduce((sum, len) => sum + len, 0);
|
|
if (availableForComponents < minTotal) {
|
|
return trailingFallback();
|
|
}
|
|
|
|
const budgets = components.map((component) => component.length);
|
|
let currentTotal = budgets.reduce((sum, len) => sum + len, 0);
|
|
|
|
const pickIndexToReduce = () => {
|
|
let bestIndex = -1;
|
|
let bestScore = -Infinity;
|
|
for (let i = 0; i < budgets.length; i++) {
|
|
if (budgets[i] <= minLengths[i]) {
|
|
continue;
|
|
}
|
|
const isLast = i === budgets.length - 1;
|
|
const score = (isLast ? 0 : 1_000_000) + budgets[i];
|
|
if (score > bestScore) {
|
|
bestScore = score;
|
|
bestIndex = i;
|
|
}
|
|
}
|
|
return bestIndex;
|
|
};
|
|
|
|
while (currentTotal > availableForComponents) {
|
|
const index = pickIndexToReduce();
|
|
if (index === -1) {
|
|
return trailingFallback();
|
|
}
|
|
budgets[index]--;
|
|
currentTotal--;
|
|
}
|
|
|
|
const truncatedComponents = components.map((component, index) =>
|
|
truncateComponent(component, budgets[index], componentModes[index]),
|
|
);
|
|
|
|
const truncatedFirst = truncatedComponents[0];
|
|
const truncatedEnd = truncatedComponents.slice(1).join(separator);
|
|
const result = `${root}${truncatedFirst}${separator}...${separator}${truncatedEnd}`;
|
|
|
|
if (result.length > maxLen) {
|
|
return trailingFallback();
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Calculates the relative path from a root directory to a target path.
|
|
* If targetPath is relative, it is returned as-is.
|
|
* Returns '.' if the target path is the same as the root directory.
|
|
*
|
|
* @param targetPath The absolute or relative path to make relative.
|
|
* @param rootDirectory The absolute path of the directory to make the target path relative to.
|
|
* @returns The relative path from rootDirectory to targetPath.
|
|
*/
|
|
export function makeRelative(
|
|
targetPath: string,
|
|
rootDirectory: string,
|
|
): string {
|
|
if (!path.isAbsolute(targetPath)) {
|
|
return targetPath;
|
|
}
|
|
const resolvedRootDirectory = path.resolve(rootDirectory);
|
|
const relativePath = path.relative(resolvedRootDirectory, targetPath);
|
|
|
|
// If the paths are the same, path.relative returns '', return '.' instead
|
|
return relativePath || '.';
|
|
}
|
|
|
|
/**
|
|
* Escape paths for at-commands.
|
|
*
|
|
* - Windows: double quoted if they contain special chars, otherwise bare
|
|
* - POSIX: backslash-escaped
|
|
*/
|
|
export function escapePath(filePath: string): string {
|
|
if (process.platform === 'win32') {
|
|
// Windows: Double quote if it contains special chars
|
|
if (/[\s&()[\]{}^=;!'+,`~%$@#]/.test(filePath)) {
|
|
return `"${filePath}"`;
|
|
}
|
|
return filePath;
|
|
} else {
|
|
// POSIX: Backslash escape
|
|
return filePath.replace(/([ \t()[\]{};|*?$`'"#&<>!~\\])/g, '\\$1');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Unescapes paths for at-commands.
|
|
*
|
|
* - Windows: double quoted if they contain special chars, otherwise bare
|
|
* - POSIX: backslash-escaped
|
|
*/
|
|
export function unescapePath(filePath: string): string {
|
|
if (process.platform === 'win32') {
|
|
if (
|
|
filePath.length >= 2 &&
|
|
filePath.startsWith('"') &&
|
|
filePath.endsWith('"')
|
|
) {
|
|
return filePath.slice(1, -1);
|
|
}
|
|
return filePath;
|
|
} else {
|
|
return filePath.replace(/\\(.)/g, '$1');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generates a unique hash for a project based on its root path.
|
|
* @param projectRoot The absolute path to the project's root directory.
|
|
* @returns A SHA256 hash of the project root path.
|
|
*/
|
|
export function getProjectHash(projectRoot: string): string {
|
|
return crypto.createHash('sha256').update(projectRoot).digest('hex');
|
|
}
|
|
|
|
/**
|
|
* Normalizes a path for reliable comparison across platforms.
|
|
* - Resolves to an absolute path.
|
|
* - Converts all path separators to forward slashes.
|
|
* - On Windows, converts to lowercase for case-insensitivity.
|
|
*/
|
|
export function normalizePath(p: string): string {
|
|
const resolved = path.resolve(p);
|
|
const normalized = resolved.replace(/\\/g, '/');
|
|
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
|
|
}
|
|
|
|
/**
|
|
* Checks if a path is a subpath of another path.
|
|
* @param parentPath The parent path.
|
|
* @param childPath The child path.
|
|
* @returns True if childPath is a subpath of parentPath, false otherwise.
|
|
*/
|
|
export function isSubpath(parentPath: string, childPath: string): boolean {
|
|
const isWindows = process.platform === 'win32';
|
|
const pathModule = isWindows ? path.win32 : path;
|
|
|
|
// On Windows, path.relative is case-insensitive. On POSIX, it's case-sensitive.
|
|
const relative = pathModule.relative(parentPath, childPath);
|
|
|
|
return (
|
|
!relative.startsWith(`..${pathModule.sep}`) &&
|
|
relative !== '..' &&
|
|
!pathModule.isAbsolute(relative)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Resolves a path to its real path, sanitizing it first.
|
|
* - Removes 'file://' protocol if present.
|
|
* - Decodes URI components (e.g. %20 -> space).
|
|
* - Resolves symbolic links using fs.realpathSync.
|
|
*
|
|
* @param pathStr The path string to resolve.
|
|
* @returns The resolved real path.
|
|
*/
|
|
export function resolveToRealPath(pathStr: string): string {
|
|
let resolvedPath = pathStr;
|
|
|
|
try {
|
|
if (resolvedPath.startsWith('file://')) {
|
|
resolvedPath = fileURLToPath(resolvedPath);
|
|
}
|
|
|
|
resolvedPath = decodeURIComponent(resolvedPath);
|
|
} catch (_e) {
|
|
// Ignore error (e.g. malformed URI), keep path from previous step
|
|
}
|
|
|
|
return robustRealpath(path.resolve(resolvedPath));
|
|
}
|
|
|
|
function robustRealpath(p: string): string {
|
|
try {
|
|
return fs.realpathSync(p);
|
|
} catch (e: unknown) {
|
|
if (e && typeof e === 'object' && 'code' in e && e.code === 'ENOENT') {
|
|
try {
|
|
const stat = fs.lstatSync(p);
|
|
if (stat.isSymbolicLink()) {
|
|
const target = fs.readlinkSync(p);
|
|
const resolvedTarget = path.resolve(path.dirname(p), target);
|
|
return robustRealpath(resolvedTarget);
|
|
}
|
|
} catch {
|
|
// Not a symlink, or lstat failed. Just resolve parent.
|
|
}
|
|
const parent = path.dirname(p);
|
|
if (parent === p) return p;
|
|
return path.join(robustRealpath(parent), path.basename(p));
|
|
}
|
|
throw e;
|
|
}
|
|
}
|