Skip to content
Merged
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
37 changes: 37 additions & 0 deletions src/__tests__/doctor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,43 @@ describe('doctor — hook checks', () => {
expect(allPassed).toBe(false);
});

// Non-self project scope: `hooks inject` writes copilot at
// <projectRoot>/.github/hooks/teamai.json (the config's own scope), so doctor
// must probe that file — not userScope.hooks joined onto projectRoot.
it('checks project Copilot hooks where inject wrote them when userScope.hooks is set', async () => {
const projectRoot = '/tmp/teamai-doctor-copilot-project-userscope';
const hookPath = path.join(projectRoot, '.github', 'hooks', 'teamai.json');
mockedLoadLocalConfig.mockResolvedValue({
...mockLocalConfig,
scope: 'project',
projectRoot,
enabledAgents: ['copilot'],
});
mockedLoadTeamConfig.mockResolvedValue({
...mockTeamConfig,
sharing: { env: { injectShellProfile: false } },
toolPaths: {
copilot: {
hooks: '.github/hooks/teamai.json',
userScope: { hooks: 'hooks/teamai.json' },
},
},
});
mockedPathExists.mockImplementation(async (filePath: string) => (
filePath === hookPath || filePath === path.dirname(hookPath)
));
mockedReadFileSafe.mockImplementation(async (filePath: string) => (
filePath === hookPath ? buildFullHooksContent() : null
));

await doctor({});
const copilotLine = consoleSpy.mock.calls
.map((call) => String(call[0]))
.find((message) => message.includes('hooks in copilot'));

expect(copilotLine).toContain('✔');
});

it('does not infer project Copilot installation from .github/hooks alone', async () => {
const projectRoot = '/tmp/teamai-doctor-unselected-copilot';
const copilotHome = '/tmp/teamai-doctor-unselected-home';
Expand Down
14 changes: 11 additions & 3 deletions src/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,15 +170,23 @@ async function buildEnabledToolChecks(ctx: DoctorContext): Promise<Check[]> {
*/
async function buildHookChecks(
toolPaths: TeamaiConfig['toolPaths'],
hookToolPaths: TeamaiConfig['toolPaths'],
baseDir: string,
localConfig: LocalConfig,
): Promise<Check[]> {
const checks: Check[] = [];
for (const [tool, paths] of Object.entries(toolPaths)) {
// A standalone hooks file (Copilot) is injected at the config's own scope
// (`reconcileTeamHooksForConfig` joins resolveToolBaseDir with the
// config-scoped `hooks`), so it is probed from `toolPaths`. Settings-based
// hooks follow resolveHookScope and are probed from `hookToolPaths`.
// Mixing the two — userScope `hooks/teamai.json` under <projectRoot> —
// reported Copilot missing right after a successful `hooks inject` (#732).
const settings = hookToolPaths[tool]?.settings;
const hookPath = paths.hooks
? path.join(resolveToolBaseDir(tool, localConfig), paths.hooks)
: paths.settings
? path.join(baseDir, paths.settings)
: settings
? path.join(baseDir, settings)
: undefined;
if (!hookPath) continue;
const settingsPath = hookPath;
Expand Down Expand Up @@ -375,7 +383,7 @@ export async function buildChecks(ctx: DoctorContext, stage: CheckStage = 'docto
+ 'can push to the team repo (run with --verbose to see the push error).',
},
...await buildEnabledToolChecks(ctx),
...await buildHookChecks(hookToolPaths, baseDir, localConfig),
...await buildHookChecks(toolPaths, hookToolPaths, baseDir, localConfig),
...await buildDeliveryChecks(ctx),
// Built only for `doctor`: the work is in building these, not in running
// them, so skipping them post-pull is what keeps the budget for the rest.
Expand Down
Loading