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:
Moisés Gana Obregón
2026-03-25 23:01:12 +00:00
parent 60be21f92e
commit cb507166bd
9 changed files with 223 additions and 5 deletions
+11 -3
View File
@@ -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;
}
+72
View File
@@ -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', () => {
+25
View File
@@ -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');