Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions packages/cli/src/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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({
Expand Down
15 changes: 13 additions & 2 deletions packages/cli/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/config/extension-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
9 changes: 9 additions & 0 deletions packages/cli/src/config/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
10 changes: 10 additions & 0 deletions packages/cli/src/config/settingsSchema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions packages/cli/src/config/settingsSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
14 changes: 11 additions & 3 deletions packages/core/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,10 @@ export interface PlanSettings {
modelRouting?: boolean;
}

export interface TrackerSettings {
directory?: string;
}

export interface TelemetrySettings {
enabled?: boolean;
target?: TelemetryTarget;
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -657,6 +665,7 @@ export interface ConfigParameters {
plan?: boolean;
tracker?: boolean;
planSettings?: PlanSettings;
trackerSettings?: TrackerSettings;
worktreeSettings?: WorktreeSettings;
modelSteering?: boolean;
onModelChange?: (model: string) => void;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down
72 changes: 72 additions & 0 deletions packages/core/src/config/storage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
25 changes: 25 additions & 0 deletions packages/core/src/config/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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) {
Expand Down Expand Up @@ -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');
Expand Down
Loading