1
1
import type { StagedLintConfig } from './types'
2
-
3
2
import { execSync } from 'node:child_process'
4
3
import fs from 'node:fs'
5
4
import path from 'node:path'
@@ -31,7 +30,7 @@ export class StagedLintProcessor {
31
30
}
32
31
33
32
this . log ( `Processing ${ stagedFiles . length } staged files` )
34
-
33
+
35
34
// Store original staged content for comparison (only if auto-restage is enabled)
36
35
const originalContent = this . autoRestage ? this . captureStagedContent ( stagedFiles ) : new Map ( )
37
36
@@ -40,12 +39,13 @@ export class StagedLintProcessor {
40
39
// Process each pattern in the config
41
40
for ( const [ pattern , commands ] of Object . entries ( config ) ) {
42
41
const matchingFiles = this . getMatchingFiles ( stagedFiles , pattern )
43
- if ( matchingFiles . length === 0 ) continue
42
+ if ( matchingFiles . length === 0 )
43
+ continue
44
44
45
45
this . log ( `Processing pattern "${ pattern } " for ${ matchingFiles . length } files` )
46
46
47
47
const commandArray = Array . isArray ( commands ) ? commands : [ commands ]
48
-
48
+
49
49
for ( const command of commandArray ) {
50
50
const success = await this . runLintCommand ( command , matchingFiles )
51
51
if ( ! success ) {
@@ -60,15 +60,16 @@ export class StagedLintProcessor {
60
60
if ( modifiedFiles . length > 0 ) {
61
61
this . log ( `Auto-restaging ${ modifiedFiles . length } modified files: ${ modifiedFiles . join ( ', ' ) } ` )
62
62
this . restageFiles ( modifiedFiles )
63
-
63
+
64
64
// Validate that restaged files still pass lint
65
65
const validationSuccess = await this . validateStagedFiles ( config )
66
66
if ( ! validationSuccess ) {
67
67
this . log ( 'Validation failed after auto-restaging' )
68
68
return false
69
69
}
70
70
}
71
- } else if ( ! this . autoRestage ) {
71
+ }
72
+ else if ( ! this . autoRestage ) {
72
73
// Check if files were modified but not restaged
73
74
const modifiedFiles = this . getModifiedFiles ( originalContent )
74
75
if ( modifiedFiles . length > 0 ) {
@@ -79,8 +80,8 @@ export class StagedLintProcessor {
79
80
}
80
81
81
82
return ! hasErrors
82
-
83
- } catch ( error ) {
83
+ }
84
+ catch ( error ) {
84
85
console . error ( `Staged lint process failed: ${ error } ` )
85
86
return false
86
87
}
@@ -90,71 +91,144 @@ export class StagedLintProcessor {
90
91
try {
91
92
const output = execSync ( 'git diff --cached --name-only' , {
92
93
cwd : this . projectRoot ,
93
- encoding : 'utf-8'
94
+ encoding : 'utf-8' ,
94
95
} )
95
96
return output . trim ( ) . split ( '\n' ) . filter ( Boolean )
96
- } catch {
97
+ }
98
+ catch {
97
99
return [ ]
98
100
}
99
101
}
100
102
101
103
private captureStagedContent ( files : string [ ] ) : Map < string , string > {
102
104
const content = new Map < string , string > ( )
103
-
105
+
104
106
for ( const file of files ) {
105
107
try {
106
108
const stagedContent = execSync ( `git show :${ file } ` , {
107
109
cwd : this . projectRoot ,
108
- encoding : 'utf-8'
110
+ encoding : 'utf-8' ,
109
111
} )
110
112
content . set ( file , stagedContent )
111
- } catch {
113
+ }
114
+ catch {
112
115
try {
113
116
const workingContent = fs . readFileSync ( path . join ( this . projectRoot , file ) , 'utf-8' )
114
117
content . set ( file , workingContent )
115
- } catch {
118
+ }
119
+ catch {
116
120
// Skip files that can't be read
117
121
}
118
122
}
119
123
}
120
-
124
+
121
125
return content
122
126
}
123
127
124
128
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 ( / _ _ D O U B L E S T A R _ _ / g, '.*' )
196
+
197
+ // Handle single character match
198
+ regexPattern = regexPattern . replace ( / \? / g, '[^/]' )
199
+
130
200
const regex = new RegExp ( `^${ regexPattern } $` )
131
- return files . filter ( file => regex . test ( file ) )
201
+ return regex . test ( file )
132
202
}
133
203
134
204
private async runLintCommand ( command : string , files : string [ ] ) : Promise < boolean > {
135
205
try {
136
- const finalCommand = command . includes ( '{files}' )
206
+ const finalCommand = command . includes ( '{files}' )
137
207
? command . replace ( '{files}' , files . join ( ' ' ) )
138
208
: `${ command } ${ files . join ( ' ' ) } `
139
209
140
210
this . log ( `Running: ${ finalCommand } ` )
141
211
142
- execSync ( finalCommand , {
212
+ const result = execSync ( finalCommand , {
143
213
cwd : this . projectRoot ,
144
- stdio : this . verbose ? 'inherit' : 'pipe'
214
+ stdio : this . verbose ? 'inherit' : 'pipe' ,
215
+ encoding : 'utf-8' ,
145
216
} )
146
217
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 )
157
220
}
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
158
232
}
159
233
}
160
234
@@ -167,7 +241,8 @@ export class StagedLintProcessor {
167
241
if ( currentContent !== originalText ) {
168
242
modifiedFiles . push ( file )
169
243
}
170
- } catch {
244
+ }
245
+ catch {
171
246
// Skip files that can't be read
172
247
}
173
248
}
@@ -176,31 +251,34 @@ export class StagedLintProcessor {
176
251
}
177
252
178
253
private restageFiles ( files : string [ ] ) : void {
179
- if ( files . length === 0 ) return
254
+ if ( files . length === 0 )
255
+ return
180
256
181
257
try {
182
258
execSync ( `git add ${ files . join ( ' ' ) } ` , {
183
259
cwd : this . projectRoot ,
184
- stdio : this . verbose ? 'inherit' : 'pipe'
260
+ stdio : this . verbose ? 'inherit' : 'pipe' ,
185
261
} )
186
- } catch ( error ) {
262
+ }
263
+ catch ( error ) {
187
264
throw new Error ( `Failed to re-stage files: ${ error } ` )
188
265
}
189
266
}
190
267
191
268
private async validateStagedFiles ( config : StagedLintConfig ) : Promise < boolean > {
192
269
const stagedFiles = this . getStagedFiles ( )
193
-
270
+
194
271
for ( const [ pattern , commands ] of Object . entries ( config ) ) {
195
272
const matchingFiles = this . getMatchingFiles ( stagedFiles , pattern )
196
- if ( matchingFiles . length === 0 ) continue
273
+ if ( matchingFiles . length === 0 )
274
+ continue
197
275
198
276
const commandArray = Array . isArray ( commands ) ? commands : [ commands ]
199
-
277
+
200
278
for ( const command of commandArray ) {
201
279
// Remove --fix flag for validation
202
280
const validationCommand = command . replace ( / - - f i x \b / g, '' ) . trim ( )
203
-
281
+
204
282
const success = await this . runLintCommand ( validationCommand , matchingFiles )
205
283
if ( ! success ) {
206
284
return false
@@ -224,9 +302,47 @@ export class StagedLintProcessor {
224
302
export async function runEnhancedStagedLint (
225
303
config : StagedLintConfig ,
226
304
projectRoot : string = process . cwd ( ) ,
227
- options : { verbose ?: boolean ; autoRestage ?: boolean } = { }
305
+ options : { verbose ?: boolean , autoRestage ?: boolean } = { } ,
228
306
) : Promise < boolean > {
229
307
const { verbose = false , autoRestage = true } = options
230
308
const processor = new StagedLintProcessor ( projectRoot , verbose , autoRestage )
231
309
return processor . process ( config )
232
310
}
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