Skip to content

Commit d44fbcc

Browse files
CLI-9 PR review
1 parent ecf05e3 commit d44fbcc

File tree

10 files changed

+167
-99
lines changed

10 files changed

+167
-99
lines changed

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ bun run test:all # Unit + integration + script tests
2323

2424
Each command lives in `src/cli/commands/`. The command tree is defined in `src/cli/command-tree.ts` and the entry point is `src/index.ts`.
2525

26-
To add a new command: add it to `src/cli/command-tree.ts` and implement the logic in a new file under `src/cli/commands/`.
26+
To add a new command: add it to `src/cli/command-tree.ts` and implement the logic in a new file under `src/cli/commands/`. The integrate git command uses a `GitRepo` type (`src/cli/commands/_common/git-repo.ts`) for hook strategy detection (pre-commit framework, Husky, or native) and lazy hooks-dir resolution.
2727

2828
## Error handling
2929

src/cli/commands/_common/discovery.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ export async function discoverProject(startDir: string): Promise<DiscoveredProje
162162
return config;
163163
}
164164

165-
function findGitRoot(startDir: string): { gitRoot: string; isGit: boolean } {
165+
export function findGitRoot(startDir: string): { gitRoot: string; isGit: boolean } {
166166
let dir = startDir;
167167

168168
for (;;) {
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
/*
2+
* SonarQube CLI
3+
* Copyright (C) 2026 SonarSource Sàrl
4+
* mailto:info AT sonarsource DOT com
5+
*
6+
* This program is free software; you can redistribute it and/or
7+
* modify it under the terms of the GNU Lesser General Public
8+
* License as published by the Free Software Foundation; either
9+
* version 3 of the License, or (at your option) any later version.
10+
*
11+
* This program is distributed in the hope that it will be useful,
12+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
14+
* Lesser General Public License for more details.
15+
*
16+
* You should have received a copy of the GNU Lesser General Public License
17+
* along with this program; if not, write to the Free Software Foundation,
18+
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19+
*/
20+
21+
// Git repository abstraction for hook installation: root dir, hooks path, and framework detection.
22+
23+
import { existsSync, statSync } from 'node:fs';
24+
import { isAbsolute, join } from 'node:path';
25+
import { CommandFailedError } from './error';
26+
import { spawnProcess } from '../../../lib/process';
27+
28+
const PRE_COMMIT_CONFIG_FILE = '.pre-commit-config.yaml';
29+
const toForwardSlash = (p: string) => p.replaceAll('\\', '/');
30+
31+
/**
32+
* Resolves the directory git uses for hooks (core.hooksPath or .git/hooks).
33+
*/
34+
export async function resolveGitHooksDir(root: string): Promise<string> {
35+
let configResult;
36+
try {
37+
configResult = await spawnProcess('git', ['config', 'core.hooksPath'], { cwd: root });
38+
} catch {
39+
configResult = null;
40+
}
41+
if (configResult?.exitCode === 0) {
42+
const configured = configResult.stdout.trim();
43+
if (configured) {
44+
return isAbsolute(configured) ? configured : join(root, configured);
45+
}
46+
}
47+
48+
const dotGit = join(root, '.git');
49+
try {
50+
if (statSync(dotGit).isDirectory()) {
51+
return join(dotGit, 'hooks');
52+
}
53+
} catch {
54+
// .git is a file (worktree) or missing — use git rev-parse
55+
}
56+
57+
let result;
58+
try {
59+
result = await spawnProcess('git', ['rev-parse', '--git-path', 'hooks'], { cwd: root });
60+
} catch (error) {
61+
const message = error instanceof Error ? error.message : String(error);
62+
throw new CommandFailedError(`Failed to run git [${message}]`);
63+
}
64+
if (result.exitCode !== 0) {
65+
const detail = [result.stderr, result.stdout].filter((s) => s.length > 0).join('\n');
66+
throw new CommandFailedError(
67+
`Could not resolve git hooks directory (exit code ${result.exitCode}) ${detail}`,
68+
);
69+
}
70+
const resolved = result.stdout.trim();
71+
return isAbsolute(resolved) ? resolved : join(root, resolved);
72+
}
73+
74+
/**
75+
* Represents a git repository at a given root. Use to decide hook installation strategy
76+
* without resolving all state up front (e.g. only resolve hooks dir when not using pre-commit).
77+
*/
78+
export class GitRepo {
79+
readonly rootDir: string;
80+
private _hooksDir: Promise<string> | null = null;
81+
82+
constructor(rootDir: string) {
83+
this.rootDir = rootDir;
84+
}
85+
86+
/** True if the repo uses the pre-commit framework (.pre-commit-config.yaml). */
87+
usesPreCommitFramework(): boolean {
88+
return existsSync(join(this.rootDir, PRE_COMMIT_CONFIG_FILE));
89+
}
90+
91+
private async getHooksDirOnce(): Promise<string> {
92+
this._hooksDir ??= resolveGitHooksDir(this.rootDir);
93+
return this._hooksDir;
94+
}
95+
96+
/** True if git's hooks path points to .husky (Husky is in use). */
97+
async usesHusky(): Promise<boolean> {
98+
const hooksDir = await this.getHooksDirOnce();
99+
return toForwardSlash(hooksDir).startsWith(toForwardSlash(join(this.rootDir, '.husky')));
100+
}
101+
102+
/** Resolved git hooks directory (core.hooksPath or .git/hooks). */
103+
async getHooksDir(): Promise<string> {
104+
return this.getHooksDirOnce();
105+
}
106+
107+
/** Path to the Husky hook file for the given hook name (e.g. 'pre-commit', 'pre-push'). */
108+
getHuskyHookPath(hook: string): string {
109+
return join(this.rootDir, '.husky', hook);
110+
}
111+
}

src/cli/commands/analyze/secrets.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -315,13 +315,9 @@ function handleScanFailure(
315315
}
316316

317317
if (exitCode === EXIT_CODE_SECRETS_FOUND) {
318-
error(`Secrets found (${scanDurationMs}ms)`);
319-
logger.error(`Secrets found, exit code: ${exitCode}`);
320318
throw new CommandFailedError(`Secrets found (${scanDurationMs}ms)`, exitCode);
321319
}
322320

323-
error(`Scan error (exit code ${exitCode}, ${scanDurationMs}ms)`);
324-
logger.error(`Scan failed with exit code: ${exitCode}`);
325321
throw new CommandFailedError(`Scan error (exit code ${exitCode})`, exitCode);
326322
}
327323

src/cli/commands/integrate/git/git-husky.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,20 +23,25 @@
2323
import { info, success } from '../../../../ui';
2424
import { HOOK_MARKER, getHuskySnippet } from './git-shell-fragments';
2525
import type { GitHookType } from '.';
26+
import { readFile, writeFile } from 'node:fs/promises';
2627

2728
export async function installViaHusky(huskyHookPath: string, hook: GitHookType): Promise<void> {
28-
const fs = await import('node:fs/promises');
2929
let content: string;
3030
try {
31-
content = await fs.readFile(huskyHookPath, 'utf-8');
32-
} catch {
33-
content = '';
31+
content = await readFile(huskyHookPath, 'utf-8');
32+
} catch (error) {
33+
const err = error as NodeJS.ErrnoException;
34+
if (err.code === 'ENOENT') {
35+
content = '';
36+
} else {
37+
throw error;
38+
}
3439
}
3540
if (content.includes(HOOK_MARKER)) {
3641
info(`Secrets check already present in .husky/${hook}.`);
3742
return;
3843
}
3944
const newContent = content ? content.trimEnd() + getHuskySnippet(hook) : getHuskySnippet(hook);
40-
await fs.writeFile(huskyHookPath, newContent, { encoding: 'utf-8', mode: 0o755 });
45+
await writeFile(huskyHookPath, newContent, { encoding: 'utf-8', mode: 0o755 });
4146
success(`${hook} hook installed (Husky detected: added to .husky/${hook}).`);
4247
}

src/cli/commands/integrate/git/git-precommit-framework.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import { join } from 'node:path';
2525
import yaml from 'js-yaml';
2626
import { spawnProcess } from '../../../../lib/process';
2727
import { CommandFailedError } from '../../_common/error';
28-
import { error, success } from '../../../../ui';
28+
import { success } from '../../../../ui';
2929
import type { GitHookType } from '.';
3030

3131
export const PRE_COMMIT_CONFIG_FILE = '.pre-commit-config.yaml';
@@ -113,10 +113,18 @@ export function upsertPreCommitConfig(root: string, stage: GitHookType): void {
113113
}
114114

115115
async function runPreCommitCommand(args: string[], cwd: string): Promise<void> {
116-
const result = await spawnProcess('pre-commit', args, { cwd });
116+
let result;
117+
try {
118+
result = await spawnProcess('pre-commit', args, { cwd });
119+
} catch (error) {
120+
const message = error instanceof Error ? error.message : String(error);
121+
throw new CommandFailedError(`Failed to run pre-commit [${message}]`);
122+
}
117123
if (result.exitCode !== 0) {
118124
const detail = [result.stderr, result.stdout].filter(Boolean).join('\n');
119-
throw new Error(`pre-commit ${args.join(' ')} failed (exit code ${result.exitCode}) ${detail}`);
125+
throw new CommandFailedError(
126+
`pre-commit ${args.join(' ')} failed (exit code ${result.exitCode}) ${detail}`,
127+
);
120128
}
121129
}
122130

@@ -144,7 +152,6 @@ export async function installViaPreCommitFramework(root: string, hook: GitHookTy
144152
await runPreCommitInstall(root, hook);
145153
} catch {
146154
const errorMessage = `Updated ${PRE_COMMIT_CONFIG_FILE} but pre-commit commands failed. Install the pre-commit framework (e.g. pip install pre-commit) and run: pre-commit uninstall && pre-commit clean && pre-commit install${hook === 'pre-push' ? ' && pre-commit install --hook-type pre-push' : ''}`;
147-
error(errorMessage);
148155
throw new CommandFailedError(errorMessage);
149156
}
150157
success(`${hook} hook installed (pre-commit framework: added to ${PRE_COMMIT_CONFIG_FILE}).`);

src/cli/commands/integrate/git/index.ts

Lines changed: 20 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,16 @@
2020

2121
// Integrate command - install git hooks for secrets scanning
2222

23-
import { existsSync, mkdirSync, readFileSync, statSync } from 'node:fs';
24-
import { isAbsolute, join } from 'node:path';
23+
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
24+
import { join } from 'node:path';
2525
import { platform } from 'node:os';
2626
import { GLOBAL_HOOKS_DIR } from '../../../../lib/config-constants';
27-
import logger from '../../../../lib/logger';
2827
import { resolveAuth } from '../../../../lib/auth-resolver';
29-
import { discoverProject } from '../../_common/discovery';
28+
import { findGitRoot } from '../../_common/discovery';
3029
import { CommandFailedError, InvalidOptionError } from '../../_common/error';
3130
import { performSecretInstall } from '../../install/secrets';
3231
import { spawnProcess } from '../../../../lib/process';
32+
import * as fs from 'node:fs/promises';
3333
import {
3434
blank,
3535
confirmPrompt,
@@ -41,6 +41,7 @@ import {
4141
text,
4242
warn,
4343
} from '../../../../ui';
44+
import { GitRepo, resolveGitHooksDir } from '../../_common/git-repo';
4445
import { HOOK_MARKER, getHookScript } from './git-shell-fragments';
4546
import { installViaHusky } from './git-husky';
4647
import {
@@ -81,47 +82,7 @@ interface HookInstallation {
8182
hooksDir: string;
8283
}
8384

84-
export async function resolveGitHooksDir(root: string): Promise<string> {
85-
// core.hooksPath takes precedence over everything — it's what git actually uses to find hooks.
86-
// Husky sets this to .husky; other tools (e.g. lefthook) may point elsewhere.
87-
let configResult;
88-
try {
89-
configResult = await spawnProcess('git', ['config', 'core.hooksPath'], { cwd: root });
90-
} catch {
91-
configResult = null;
92-
}
93-
if (configResult?.exitCode === 0) {
94-
const configured = configResult.stdout.trim();
95-
if (configured) {
96-
return isAbsolute(configured) ? configured : join(root, configured);
97-
}
98-
}
99-
100-
const dotGit = join(root, '.git');
101-
try {
102-
// Standard repo: .git is a directory — hooks live directly inside it, no subprocess needed
103-
if (statSync(dotGit).isDirectory()) {
104-
return join(dotGit, 'hooks');
105-
}
106-
} catch {
107-
// .git doesn't exist; fall through to git rev-parse
108-
}
109-
// Worktree or submodule: .git is a file pointer — ask git for the real hooks path
110-
let result;
111-
try {
112-
result = await spawnProcess('git', ['rev-parse', '--git-path', 'hooks'], { cwd: root });
113-
} catch {
114-
const errorMessage = 'git is not installed or not on PATH';
115-
throw new CommandFailedError(errorMessage);
116-
}
117-
if (result.exitCode !== 0) {
118-
const detail = [result.stderr, result.stdout].filter(Boolean).join('\n');
119-
const errorMessage = `Could not resolve git hooks directory (exit code ${result.exitCode}) ${detail}`;
120-
throw new CommandFailedError(errorMessage);
121-
}
122-
const resolved = result.stdout.trim();
123-
return isAbsolute(resolved) ? resolved : join(root, resolved);
124-
}
85+
export { resolveGitHooksDir } from '../../_common/git-repo';
12586

12687
export async function detectSonarHookInstallation(root: string): Promise<HookInstallation> {
12788
let hooksDir: string;
@@ -235,12 +196,11 @@ export async function installViaGitHooks(
235196
): Promise<void> {
236197
mkdirSync(hooksDir, { recursive: true });
237198
const hookPath = join(hooksDir, hook);
238-
const fs = await import('node:fs/promises');
239199
if (existsSync(hookPath)) {
240200
const existing = await fs.readFile(hookPath, 'utf-8');
241201
if (!existing.includes(HOOK_MARKER) && !force) {
242202
warn(`A different ${hook} hook already exists at ${hookPath}.`);
243-
text(' Use --force to replace it, or add the secrets check manually.');
203+
text(' Use --force to replace it.');
244204
throw new CommandFailedError(
245205
`Refusing to overwrite existing ${hook} hook at ${hookPath}. Use --force to replace.`,
246206
);
@@ -291,14 +251,13 @@ async function integrateGitGlobal(options: IntegrateGitOptions): Promise<void> {
291251
'core.hooksPath',
292252
toForwardSlash(GLOBAL_HOOKS_DIR),
293253
]);
294-
} catch {
295-
const msg = 'git is not installed or not on PATH';
296-
throw new CommandFailedError(msg);
254+
} catch (error) {
255+
const message = error instanceof Error ? error.message : String(error);
256+
throw new CommandFailedError(`Failed to run git [${message}]`);
297257
}
298258
if (gitResult.exitCode !== 0) {
299259
const detail = [gitResult.stderr, gitResult.stdout].filter(Boolean).join('\n');
300260
const msg = `git config --global core.hooksPath failed (exit code ${gitResult.exitCode}): ${detail}`;
301-
logger.error(msg);
302261
throw new CommandFailedError(msg);
303262
}
304263

@@ -322,14 +281,14 @@ export async function integrateGit(options: IntegrateGitOptions): Promise<void>
322281
return integrateGitGlobal(options);
323282
}
324283

325-
const projectInfo = await discoverProject(process.cwd());
326-
if (!projectInfo.isGitRepo) {
284+
const { gitRoot, isGit } = findGitRoot(process.cwd());
285+
if (!isGit) {
327286
const errorMessage =
328287
'No git repository found. Please run this command from inside a git repository, or use --global to install a global hook.';
329288
throw new CommandFailedError(errorMessage);
330289
}
331290

332-
text(`We will install the hook in this repository: ${projectInfo.rootDir}`);
291+
text(`We will install the hook in this repository: ${gitRoot}`);
333292
blank();
334293

335294
if (!options.nonInteractive) {
@@ -340,29 +299,22 @@ export async function integrateGit(options: IntegrateGitOptions): Promise<void>
340299
}
341300
blank();
342301

343-
const installation = await detectSonarHookInstallation(projectInfo.rootDir);
344-
const useHusky = toForwardSlash(installation.hooksDir).startsWith(
345-
toForwardSlash(join(projectInfo.rootDir, '.husky')),
346-
);
347-
const usePreCommitConfig = existsSync(join(projectInfo.rootDir, PRE_COMMIT_CONFIG_FILE));
348-
302+
const gitRepo = new GitRepo(gitRoot);
349303
const hook = await resolveHookType(options);
350304
text(`Hook: ${hook}`);
351305
blank();
352306

353307
await ensureSonarSecrets();
354308

355-
const huskyHookPath = join(projectInfo.rootDir, '.husky', hook);
356-
357-
if (usePreCommitConfig) {
358-
await installViaPreCommitFramework(projectInfo.rootDir, hook);
359-
} else if (useHusky) {
360-
await installViaHusky(huskyHookPath, hook);
309+
if (gitRepo.usesPreCommitFramework()) {
310+
await installViaPreCommitFramework(gitRepo.rootDir, hook);
311+
} else if (await gitRepo.usesHusky()) {
312+
await installViaHusky(gitRepo.getHuskyHookPath(hook), hook);
361313
} else {
362-
await installViaGitHooks(installation.hooksDir, hook, options.force);
314+
await installViaGitHooks(await gitRepo.getHooksDir(), hook, options.force);
363315
}
364316

365317
showPostInstallInfo(hook);
366-
await showInstallationStatus(projectInfo.rootDir);
318+
await showInstallationStatus(gitRoot);
367319
showVerificationGuide(hook);
368320
}

0 commit comments

Comments
 (0)