Skip to content

Commit 49df507

Browse files
committed
chore: wip
chore: wip chore: wip chore: wip
1 parent b3e7f73 commit 49df507

File tree

12 files changed

+909
-56
lines changed

12 files changed

+909
-56
lines changed

bin/cli.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ import process from 'node:process'
33
import { Logger } from '@stacksjs/clarity'
44
import { CAC } from 'cac'
55
import { version } from '../package.json'
6-
import { removeHooks, runStagedLint, setHooksFromConfig } from '../src/git-hooks'
6+
import { config } from '../src/config'
7+
import { removeHooks, setHooksFromConfig } from '../src/git-hooks'
8+
import { runStagedLint } from '../src/staged-lint'
79

810
const cli = new CAC('git-hooks')
911
const log = new Logger('git-hooks', {
@@ -77,14 +79,14 @@ cli
7779
.example('git-hooks run-staged-lint pre-commit --verbose')
7880
.example('git-hooks run-staged-lint pre-commit --auto-restage')
7981
.example('git-hooks run-staged-lint pre-commit --no-auto-restage')
80-
.action(async (hook: string, options?: { verbose?: boolean; autoRestage?: boolean }) => {
82+
.action(async (hook: string, options?: { verbose?: boolean, autoRestage?: boolean }) => {
8183
try {
8284
if (options?.verbose) {
8385
log.debug(`Running staged lint for hook: ${hook}`)
8486
log.debug(`Working directory: ${process.cwd()}`)
8587
}
8688

87-
const success = await runStagedLint(hook, options?.verbose)
89+
const success = await runStagedLint(hook, config, process.cwd(), options?.verbose)
8890

8991
if (success) {
9092
log.success('Staged lint completed successfully')

git-hooks.config.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
import type { GitHooksConfig } from './src/types'
22

33
const config: GitHooksConfig = {
4-
54
// Hook-specific configuration (takes precedence)
65
'pre-commit': {
76
'staged-lint': {
87
'**/*.{js,ts}': [
9-
'bunx --bun eslint --max-warnings=0',
8+
'bunx --bun eslint --fix',
109
'bunx --bun tsc --noEmit'
1110
]
1211
}

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export * from './config'
22
export * from './git-hooks'
3+
export * from './staged-lint'
34
export * from './types'

src/staged-lint-utils.ts renamed to src/staged-lint.ts

Lines changed: 159 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import type { StagedLintConfig } from './types'
2-
32
import { execSync } from 'node:child_process'
43
import fs from 'node:fs'
54
import path from 'node:path'
@@ -31,7 +30,7 @@ export class StagedLintProcessor {
3130
}
3231

3332
this.log(`Processing ${stagedFiles.length} staged files`)
34-
33+
3534
// Store original staged content for comparison (only if auto-restage is enabled)
3635
const originalContent = this.autoRestage ? this.captureStagedContent(stagedFiles) : new Map()
3736

@@ -40,12 +39,13 @@ export class StagedLintProcessor {
4039
// Process each pattern in the config
4140
for (const [pattern, commands] of Object.entries(config)) {
4241
const matchingFiles = this.getMatchingFiles(stagedFiles, pattern)
43-
if (matchingFiles.length === 0) continue
42+
if (matchingFiles.length === 0)
43+
continue
4444

4545
this.log(`Processing pattern "${pattern}" for ${matchingFiles.length} files`)
4646

4747
const commandArray = Array.isArray(commands) ? commands : [commands]
48-
48+
4949
for (const command of commandArray) {
5050
const success = await this.runLintCommand(command, matchingFiles)
5151
if (!success) {
@@ -60,15 +60,16 @@ export class StagedLintProcessor {
6060
if (modifiedFiles.length > 0) {
6161
this.log(`Auto-restaging ${modifiedFiles.length} modified files: ${modifiedFiles.join(', ')}`)
6262
this.restageFiles(modifiedFiles)
63-
63+
6464
// Validate that restaged files still pass lint
6565
const validationSuccess = await this.validateStagedFiles(config)
6666
if (!validationSuccess) {
6767
this.log('Validation failed after auto-restaging')
6868
return false
6969
}
7070
}
71-
} else if (!this.autoRestage) {
71+
}
72+
else if (!this.autoRestage) {
7273
// Check if files were modified but not restaged
7374
const modifiedFiles = this.getModifiedFiles(originalContent)
7475
if (modifiedFiles.length > 0) {
@@ -79,8 +80,8 @@ export class StagedLintProcessor {
7980
}
8081

8182
return !hasErrors
82-
83-
} catch (error) {
83+
}
84+
catch (error) {
8485
console.error(`Staged lint process failed: ${error}`)
8586
return false
8687
}
@@ -90,71 +91,144 @@ export class StagedLintProcessor {
9091
try {
9192
const output = execSync('git diff --cached --name-only', {
9293
cwd: this.projectRoot,
93-
encoding: 'utf-8'
94+
encoding: 'utf-8',
9495
})
9596
return output.trim().split('\n').filter(Boolean)
96-
} catch {
97+
}
98+
catch {
9799
return []
98100
}
99101
}
100102

101103
private captureStagedContent(files: string[]): Map<string, string> {
102104
const content = new Map<string, string>()
103-
105+
104106
for (const file of files) {
105107
try {
106108
const stagedContent = execSync(`git show :${file}`, {
107109
cwd: this.projectRoot,
108-
encoding: 'utf-8'
110+
encoding: 'utf-8',
109111
})
110112
content.set(file, stagedContent)
111-
} catch {
113+
}
114+
catch {
112115
try {
113116
const workingContent = fs.readFileSync(path.join(this.projectRoot, file), 'utf-8')
114117
content.set(file, workingContent)
115-
} catch {
118+
}
119+
catch {
116120
// Skip files that can't be read
117121
}
118122
}
119123
}
120-
124+
121125
return content
122126
}
123127

124128
private getMatchingFiles(files: string[], pattern: string): string[] {
125-
const regexPattern = pattern
126-
.replace(/\./g, '\\.')
127-
.replace(/\*/g, '.*')
128-
.replace(/\?/g, '.')
129-
129+
// Handle brace expansion like {js,ts}
130+
const expandedPatterns = this.expandBracePattern(pattern)
131+
132+
// Split into include and exclude patterns
133+
const includePatterns = expandedPatterns.filter(p => !p.startsWith('!'))
134+
const excludePatterns = expandedPatterns.filter(p => p.startsWith('!'))
135+
136+
return files.filter((file) => {
137+
// If there are include patterns, file must match at least one
138+
const isIncluded = includePatterns.length === 0 || includePatterns.some(p => this.matchesGlob(file, p))
139+
140+
// File must not match any exclude pattern
141+
const isExcluded = excludePatterns.some(p => this.matchesGlob(file, p.slice(1)))
142+
143+
return isIncluded && !isExcluded
144+
})
145+
}
146+
147+
/**
148+
* Expands brace patterns like {js,ts} into [js, ts]
149+
*/
150+
private expandBracePattern(pattern: string): string[] {
151+
const braceMatch = pattern.match(/\{([^}]+)\}/g)
152+
if (!braceMatch)
153+
return [pattern]
154+
155+
const results: string[] = [pattern]
156+
braceMatch.forEach((brace) => {
157+
const options = brace.slice(1, -1).split(',')
158+
const newResults: string[] = []
159+
160+
results.forEach((result) => {
161+
options.forEach((option) => {
162+
newResults.push(result.replace(brace, option.trim()))
163+
})
164+
})
165+
166+
results.length = 0
167+
results.push(...newResults)
168+
})
169+
170+
return results
171+
}
172+
173+
/**
174+
* Checks if a file matches a glob pattern
175+
*/
176+
private matchesGlob(file: string, pattern: string): boolean {
177+
// Handle negation patterns (e.g., !node_modules/**)
178+
if (pattern.startsWith('!')) {
179+
return !this.matchesGlob(file, pattern.slice(1))
180+
}
181+
182+
// Convert glob pattern to regex step by step
183+
let regexPattern = pattern
184+
185+
// First, escape special regex characters except * and ?
186+
regexPattern = regexPattern.replace(/[.+^${}()|[\]\\]/g, '\\$&')
187+
188+
// Replace ** with a placeholder to avoid conflicts
189+
regexPattern = regexPattern.replace(/\*\*/g, '__DOUBLESTAR__')
190+
191+
// Replace single * with pattern that doesn't cross directory boundaries
192+
regexPattern = regexPattern.replace(/\*/g, '[^/]*')
193+
194+
// Replace ** placeholder with pattern that can cross directories
195+
regexPattern = regexPattern.replace(/__DOUBLESTAR__/g, '.*')
196+
197+
// Handle single character match
198+
regexPattern = regexPattern.replace(/\?/g, '[^/]')
199+
130200
const regex = new RegExp(`^${regexPattern}$`)
131-
return files.filter(file => regex.test(file))
201+
return regex.test(file)
132202
}
133203

134204
private async runLintCommand(command: string, files: string[]): Promise<boolean> {
135205
try {
136-
const finalCommand = command.includes('{files}')
206+
const finalCommand = command.includes('{files}')
137207
? command.replace('{files}', files.join(' '))
138208
: `${command} ${files.join(' ')}`
139209

140210
this.log(`Running: ${finalCommand}`)
141211

142-
execSync(finalCommand, {
212+
const result = execSync(finalCommand, {
143213
cwd: this.projectRoot,
144-
stdio: this.verbose ? 'inherit' : 'pipe'
214+
stdio: this.verbose ? 'inherit' : 'pipe',
215+
encoding: 'utf-8',
145216
})
146217

147-
return true
148-
} catch (error: any) {
149-
if (error.status === 1) {
150-
// Lint errors
151-
console.error(`Lint errors in files: ${files.join(', ')}`)
152-
return false
153-
} else {
154-
// Other errors
155-
console.error(`Command failed: ${command} - ${error.message}`)
156-
return false
218+
if (this.verbose && result) {
219+
console.warn(result)
157220
}
221+
222+
return true
223+
}
224+
catch (error: any) {
225+
// Any non-zero exit code indicates failure
226+
if (error.stdout && this.verbose)
227+
console.warn(error.stdout)
228+
if (error.stderr)
229+
console.error('[ERROR] Command stderr:', error.stderr)
230+
console.error(`[ERROR] Command failed: ${command}`)
231+
return false
158232
}
159233
}
160234

@@ -167,7 +241,8 @@ export class StagedLintProcessor {
167241
if (currentContent !== originalText) {
168242
modifiedFiles.push(file)
169243
}
170-
} catch {
244+
}
245+
catch {
171246
// Skip files that can't be read
172247
}
173248
}
@@ -176,31 +251,34 @@ export class StagedLintProcessor {
176251
}
177252

178253
private restageFiles(files: string[]): void {
179-
if (files.length === 0) return
254+
if (files.length === 0)
255+
return
180256

181257
try {
182258
execSync(`git add ${files.join(' ')}`, {
183259
cwd: this.projectRoot,
184-
stdio: this.verbose ? 'inherit' : 'pipe'
260+
stdio: this.verbose ? 'inherit' : 'pipe',
185261
})
186-
} catch (error) {
262+
}
263+
catch (error) {
187264
throw new Error(`Failed to re-stage files: ${error}`)
188265
}
189266
}
190267

191268
private async validateStagedFiles(config: StagedLintConfig): Promise<boolean> {
192269
const stagedFiles = this.getStagedFiles()
193-
270+
194271
for (const [pattern, commands] of Object.entries(config)) {
195272
const matchingFiles = this.getMatchingFiles(stagedFiles, pattern)
196-
if (matchingFiles.length === 0) continue
273+
if (matchingFiles.length === 0)
274+
continue
197275

198276
const commandArray = Array.isArray(commands) ? commands : [commands]
199-
277+
200278
for (const command of commandArray) {
201279
// Remove --fix flag for validation
202280
const validationCommand = command.replace(/--fix\b/g, '').trim()
203-
281+
204282
const success = await this.runLintCommand(validationCommand, matchingFiles)
205283
if (!success) {
206284
return false
@@ -224,9 +302,47 @@ export class StagedLintProcessor {
224302
export async function runEnhancedStagedLint(
225303
config: StagedLintConfig,
226304
projectRoot: string = process.cwd(),
227-
options: { verbose?: boolean; autoRestage?: boolean } = {}
305+
options: { verbose?: boolean, autoRestage?: boolean } = {},
228306
): Promise<boolean> {
229307
const { verbose = false, autoRestage = true } = options
230308
const processor = new StagedLintProcessor(projectRoot, verbose, autoRestage)
231309
return processor.process(config)
232310
}
311+
312+
/**
313+
* Main staged lint function that should be used by git hooks
314+
* This is the primary entry point for staged lint functionality
315+
*/
316+
export async function runStagedLint(
317+
hook: string,
318+
config: any,
319+
projectRoot: string = process.cwd(),
320+
verbose: boolean = false,
321+
): Promise<boolean> {
322+
if (!config) {
323+
console.error(`[ERROR] No configuration found`)
324+
return false
325+
}
326+
327+
// First check for hook-specific configuration
328+
if (hook in config) {
329+
const hookConfig = config[hook as keyof typeof config]
330+
if (typeof hookConfig === 'object' && !Array.isArray(hookConfig)) {
331+
const stagedLintConfig = (hookConfig as { 'stagedLint'?: StagedLintConfig, 'staged-lint'?: StagedLintConfig }).stagedLint
332+
|| (hookConfig as { 'stagedLint'?: StagedLintConfig, 'staged-lint'?: StagedLintConfig })['staged-lint']
333+
if (stagedLintConfig) {
334+
const processor = new StagedLintProcessor(projectRoot, verbose, true)
335+
return processor.process(stagedLintConfig)
336+
}
337+
}
338+
}
339+
340+
// If no hook-specific configuration, check for global staged-lint
341+
if (config['staged-lint']) {
342+
const processor = new StagedLintProcessor(projectRoot, verbose, true)
343+
return processor.process(config['staged-lint'])
344+
}
345+
346+
console.error(`[ERROR] No staged lint configuration found for hook ${hook}`)
347+
return false
348+
}

0 commit comments

Comments
 (0)