mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-15 14:23:02 -07:00
feat(tracker): support extension-contributed tracker directory and ensure project precedence
- Add 'tracker' property to ExtensionConfig to allow extensions to specify task tracking directories. - Fix extension loading to pass tracker configuration from extensions. - Implement project-level precedence for tracker directory over extension defaults. - Add comprehensive tests for tracker directory resolution and precedence.
This commit is contained in:
@@ -3688,6 +3688,46 @@ describe('loadCliConfig mcpEnabled', () => {
|
||||
expect(config.storage.getPlansDir()).not.toContain('ext-plans-dir');
|
||||
});
|
||||
|
||||
it('should use tracker directory from active extension when user has not specified one', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const settings = createTestMergedSettings({
|
||||
experimental: { plan: true },
|
||||
});
|
||||
const argv = await parseArguments(settings);
|
||||
|
||||
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([
|
||||
{
|
||||
name: 'ext-tracker',
|
||||
isActive: true,
|
||||
tracker: { directory: 'ext-tracker-dir' },
|
||||
} as unknown as GeminiCLIExtension,
|
||||
]);
|
||||
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
expect(config.storage.getTrackerDir()).toContain('ext-tracker-dir');
|
||||
});
|
||||
|
||||
it('should NOT use tracker directory from active extension when user has specified one', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const settings = createTestMergedSettings({
|
||||
general: { tracker: { directory: 'user-tracker-dir' } },
|
||||
experimental: { plan: true },
|
||||
});
|
||||
const argv = await parseArguments(settings);
|
||||
|
||||
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([
|
||||
{
|
||||
name: 'ext-tracker',
|
||||
isActive: true,
|
||||
tracker: { directory: 'ext-tracker-dir' },
|
||||
} as unknown as GeminiCLIExtension,
|
||||
]);
|
||||
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
expect(config.storage.getTrackerDir()).toContain('user-tracker-dir');
|
||||
expect(config.storage.getTrackerDir()).not.toContain('ext-tracker-dir');
|
||||
});
|
||||
|
||||
it('should NOT use plan directory from inactive extension', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const settings = createTestMergedSettings({
|
||||
@@ -3709,6 +3749,27 @@ describe('loadCliConfig mcpEnabled', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should NOT use tracker directory from inactive extension', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const settings = createTestMergedSettings({
|
||||
experimental: { plan: true },
|
||||
});
|
||||
const argv = await parseArguments(settings);
|
||||
|
||||
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([
|
||||
{
|
||||
name: 'ext-tracker',
|
||||
isActive: false,
|
||||
tracker: { directory: 'ext-tracker-dir-inactive' },
|
||||
} as unknown as GeminiCLIExtension,
|
||||
]);
|
||||
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
expect(config.storage.getTrackerDir()).not.toContain(
|
||||
'ext-tracker-dir-inactive',
|
||||
);
|
||||
});
|
||||
|
||||
it('should use default path if neither user nor extension settings provide a plan directory', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const settings = createTestMergedSettings({
|
||||
|
||||
@@ -570,9 +570,17 @@ export async function loadCliConfig(
|
||||
});
|
||||
await extensionManager.loadExtensions();
|
||||
|
||||
const extensionPlanSettings = extensionManager
|
||||
const activeExtensions = extensionManager
|
||||
.getExtensions()
|
||||
.find((ext) => ext.isActive && ext.plan?.directory)?.plan;
|
||||
.filter((ext) => ext.isActive);
|
||||
|
||||
const extensionPlanSettings = activeExtensions.find(
|
||||
(ext) => !!ext.plan?.directory,
|
||||
)?.plan;
|
||||
|
||||
const extensionTrackerSettings = activeExtensions.find(
|
||||
(ext) => !!ext.tracker?.directory,
|
||||
)?.tracker;
|
||||
|
||||
const experimentalJitContext = settings.experimental.jitContext;
|
||||
|
||||
@@ -931,6 +939,9 @@ export async function loadCliConfig(
|
||||
planSettings: settings.general?.plan?.directory
|
||||
? settings.general.plan
|
||||
: (extensionPlanSettings ?? settings.general?.plan),
|
||||
trackerSettings: settings.general?.tracker?.directory
|
||||
? settings.general.tracker
|
||||
: (extensionTrackerSettings ?? settings.general?.tracker),
|
||||
enableEventDrivenScheduler: true,
|
||||
skillsSupport: settings.skills?.enabled ?? true,
|
||||
disabledSkills: settings.skills?.disabled,
|
||||
|
||||
@@ -1050,6 +1050,7 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
rules,
|
||||
checkers,
|
||||
plan: config.plan,
|
||||
tracker: config.tracker,
|
||||
};
|
||||
} catch (e) {
|
||||
const extName = path.basename(extensionDir);
|
||||
|
||||
@@ -42,6 +42,15 @@ export interface ExtensionConfig {
|
||||
*/
|
||||
directory?: string;
|
||||
};
|
||||
/**
|
||||
* Task tracking configuration contributed by this extension.
|
||||
*/
|
||||
tracker?: {
|
||||
/**
|
||||
* The directory where task tracking data is stored.
|
||||
*/
|
||||
directory?: string;
|
||||
};
|
||||
/**
|
||||
* Used to migrate an extension to a new repository source.
|
||||
*/
|
||||
|
||||
@@ -125,6 +125,16 @@ describe('SettingsSchema', () => {
|
||||
).toBe('string');
|
||||
});
|
||||
|
||||
it('should have tracker nested properties', () => {
|
||||
expect(
|
||||
getSettingsSchema().general?.properties?.tracker?.properties?.directory,
|
||||
).toBeDefined();
|
||||
expect(
|
||||
getSettingsSchema().general?.properties?.tracker?.properties?.directory
|
||||
.type,
|
||||
).toBe('string');
|
||||
});
|
||||
|
||||
it('should have fileFiltering nested properties', () => {
|
||||
expect(
|
||||
getSettingsSchema().context.properties.fileFiltering.properties
|
||||
|
||||
@@ -315,6 +315,27 @@ const SETTINGS_SCHEMA = {
|
||||
},
|
||||
},
|
||||
},
|
||||
tracker: {
|
||||
type: 'object',
|
||||
label: 'Task Tracker',
|
||||
category: 'General',
|
||||
requiresRestart: true,
|
||||
default: {},
|
||||
description: 'Task tracking features configuration.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
directory: {
|
||||
type: 'string',
|
||||
label: 'Tracker Directory',
|
||||
category: 'General',
|
||||
requiresRestart: true,
|
||||
default: undefined as string | undefined,
|
||||
description:
|
||||
'The directory where task tracking data is stored. If not specified, defaults to the system temporary directory.',
|
||||
showInDialog: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
retryFetchErrors: {
|
||||
type: 'boolean',
|
||||
label: 'Retry Fetch Errors',
|
||||
|
||||
@@ -184,6 +184,10 @@ export interface PlanSettings {
|
||||
modelRouting?: boolean;
|
||||
}
|
||||
|
||||
export interface TrackerSettings {
|
||||
directory?: string;
|
||||
}
|
||||
|
||||
export interface TelemetrySettings {
|
||||
enabled?: boolean;
|
||||
target?: TelemetryTarget;
|
||||
@@ -375,6 +379,10 @@ export interface GeminiCLIExtension {
|
||||
*/
|
||||
directory?: string;
|
||||
};
|
||||
/**
|
||||
* Task tracking configuration contributed by this extension.
|
||||
*/
|
||||
tracker?: TrackerSettings;
|
||||
/**
|
||||
* Used to migrate an extension to a new repository source.
|
||||
*/
|
||||
@@ -657,6 +665,7 @@ export interface ConfigParameters {
|
||||
plan?: boolean;
|
||||
tracker?: boolean;
|
||||
planSettings?: PlanSettings;
|
||||
trackerSettings?: TrackerSettings;
|
||||
worktreeSettings?: WorktreeSettings;
|
||||
modelSteering?: boolean;
|
||||
onModelChange?: (model: string) => void;
|
||||
@@ -1136,6 +1145,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
this.enableExtensionReloading = params.enableExtensionReloading ?? false;
|
||||
this.storage = new Storage(this.targetDir, this._sessionId);
|
||||
this.storage.setCustomPlansDir(params.planSettings?.directory);
|
||||
this.storage.setCustomTrackerDir(params.trackerSettings?.directory);
|
||||
|
||||
this.fakeResponses = params.fakeResponses;
|
||||
this.recordResponses = params.recordResponses;
|
||||
@@ -2541,9 +2551,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
|
||||
getTrackerService(): TrackerService {
|
||||
if (!this.trackerService) {
|
||||
this.trackerService = new TrackerService(
|
||||
this.storage.getProjectTempTrackerDir(),
|
||||
);
|
||||
this.trackerService = new TrackerService(this.storage.getTrackerDir());
|
||||
}
|
||||
return this.trackerService;
|
||||
}
|
||||
|
||||
@@ -371,6 +371,78 @@ describe('Storage – additional helpers', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTrackerDir', () => {
|
||||
interface TestCase {
|
||||
name: string;
|
||||
customDir: string | undefined;
|
||||
expected: string | (() => string);
|
||||
expectedError?: string;
|
||||
setup?: () => () => void;
|
||||
}
|
||||
|
||||
const testCases: TestCase[] = [
|
||||
{
|
||||
name: 'custom relative path',
|
||||
customDir: '.gemini/tracker',
|
||||
expected: path.resolve(projectRoot, '.gemini/tracker'),
|
||||
},
|
||||
{
|
||||
name: 'custom absolute path outside throws',
|
||||
customDir: '/absolute/path/to/tracker',
|
||||
expected: '',
|
||||
expectedError: `Custom tracker directory '/absolute/path/to/tracker' resolves to '/absolute/path/to/tracker', which is outside the project root '${resolveToRealPath(projectRoot)}'.`,
|
||||
},
|
||||
{
|
||||
name: 'absolute path that happens to be inside project root',
|
||||
customDir: path.join(projectRoot, '.gemini/tracker'),
|
||||
expected: path.join(projectRoot, '.gemini/tracker'),
|
||||
},
|
||||
{
|
||||
name: 'relative path that stays within project root',
|
||||
customDir: 'subdir/../tracker',
|
||||
expected: path.resolve(projectRoot, 'tracker'),
|
||||
},
|
||||
{
|
||||
name: 'dot path',
|
||||
customDir: '.',
|
||||
expected: projectRoot,
|
||||
},
|
||||
{
|
||||
name: 'default behavior when customDir is undefined',
|
||||
customDir: undefined,
|
||||
expected: () => storage.getProjectTempTrackerDir(),
|
||||
},
|
||||
{
|
||||
name: 'escaping relative path throws',
|
||||
customDir: '../escaped-tracker',
|
||||
expected: '',
|
||||
expectedError: `Custom tracker directory '../escaped-tracker' resolves to '${resolveToRealPath(path.resolve(projectRoot, '../escaped-tracker'))}', which is outside the project root '${resolveToRealPath(projectRoot)}'.`,
|
||||
},
|
||||
];
|
||||
|
||||
testCases.forEach(({ name, customDir, expected, expectedError, setup }) => {
|
||||
it(`should handle ${name}`, async () => {
|
||||
const cleanup = setup?.();
|
||||
try {
|
||||
if (name.includes('default behavior')) {
|
||||
await storage.initialize();
|
||||
}
|
||||
|
||||
storage.setCustomTrackerDir(customDir);
|
||||
if (expectedError) {
|
||||
expect(() => storage.getTrackerDir()).toThrow(expectedError);
|
||||
} else {
|
||||
const expectedValue =
|
||||
typeof expected === 'function' ? expected() : expected;
|
||||
expect(storage.getTrackerDir()).toBe(expectedValue);
|
||||
}
|
||||
} finally {
|
||||
cleanup?.();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Storage - System Paths', () => {
|
||||
|
||||
@@ -32,6 +32,7 @@ export class Storage {
|
||||
private projectIdentifier: string | undefined;
|
||||
private initPromise: Promise<void> | undefined;
|
||||
private customPlansDir: string | undefined;
|
||||
private customTrackerDir: string | undefined;
|
||||
|
||||
constructor(targetDir: string, sessionId?: string) {
|
||||
this.targetDir = targetDir;
|
||||
@@ -42,6 +43,10 @@ export class Storage {
|
||||
this.customPlansDir = dir;
|
||||
}
|
||||
|
||||
setCustomTrackerDir(dir: string | undefined): void {
|
||||
this.customTrackerDir = dir;
|
||||
}
|
||||
|
||||
static getGlobalGeminiDir(): string {
|
||||
const homeDir = homedir();
|
||||
if (!homeDir) {
|
||||
@@ -328,6 +333,26 @@ export class Storage {
|
||||
return this.getProjectTempPlansDir();
|
||||
}
|
||||
|
||||
getTrackerDir(): string {
|
||||
if (this.customTrackerDir) {
|
||||
const resolvedPath = path.resolve(
|
||||
this.getProjectRoot(),
|
||||
this.customTrackerDir,
|
||||
);
|
||||
const realProjectRoot = resolveToRealPath(this.getProjectRoot());
|
||||
const realResolvedPath = resolveToRealPath(resolvedPath);
|
||||
|
||||
if (!isSubpath(realProjectRoot, realResolvedPath)) {
|
||||
throw new Error(
|
||||
`Custom tracker directory '${this.customTrackerDir}' resolves to '${realResolvedPath}', which is outside the project root '${realProjectRoot}'.`,
|
||||
);
|
||||
}
|
||||
|
||||
return resolvedPath;
|
||||
}
|
||||
return this.getProjectTempTrackerDir();
|
||||
}
|
||||
|
||||
getProjectTempTasksDir(): string {
|
||||
if (this.sessionId) {
|
||||
return path.join(this.getProjectTempDir(), this.sessionId, 'tasks');
|
||||
|
||||
Reference in New Issue
Block a user