Skip to content

Commit b3e7f73

Browse files
committed
chore: wip
1 parent cfaa213 commit b3e7f73

File tree

7 files changed

+343
-3
lines changed

7 files changed

+343
-3
lines changed

bin/cli.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,9 +71,13 @@ cli
7171
cli
7272
.command('run-staged-lint <hook>', 'Run staged lint for a specific git hook')
7373
.option('--verbose', 'Enable verbose logging')
74+
.option('--auto-restage', 'Automatically re-stage files after lint fixes')
75+
.option('--no-auto-restage', 'Do not automatically re-stage files after lint fixes')
7476
.example('git-hooks run-staged-lint pre-commit')
75-
.example('git-hooks run-staged-lint pre-push --verbose')
76-
.action(async (hook: string, options?: { verbose?: boolean }) => {
77+
.example('git-hooks run-staged-lint pre-commit --verbose')
78+
.example('git-hooks run-staged-lint pre-commit --auto-restage')
79+
.example('git-hooks run-staged-lint pre-commit --no-auto-restage')
80+
.action(async (hook: string, options?: { verbose?: boolean; autoRestage?: boolean }) => {
7781
try {
7882
if (options?.verbose) {
7983
log.debug(`Running staged lint for hook: ${hook}`)

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,8 @@
9494
"pre-commit": {
9595
"staged-lint": {
9696
"*.{js,ts,json,yaml,yml,md}": "bunx --bun eslint --fix"
97-
}
97+
},
98+
"autoRestage": true
9899
},
99100
"commit-msg": "bunx gitlint --edit .git/COMMIT_EDITMSG"
100101
},

src/staged-lint-utils.ts

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
import type { StagedLintConfig } from './types'
2+
3+
import { execSync } from 'node:child_process'
4+
import fs from 'node:fs'
5+
import path from 'node:path'
6+
import process from 'node:process'
7+
8+
/**
9+
* Enhanced staged lint processor with configurable auto-restaging
10+
*/
11+
export class StagedLintProcessor {
12+
private projectRoot: string
13+
private verbose: boolean
14+
private autoRestage: boolean
15+
16+
constructor(projectRoot: string = process.cwd(), verbose: boolean = false, autoRestage: boolean = true) {
17+
this.projectRoot = projectRoot
18+
this.verbose = verbose
19+
this.autoRestage = autoRestage
20+
}
21+
22+
/**
23+
* Process staged lint with optional auto-restaging
24+
*/
25+
async process(config: StagedLintConfig): Promise<boolean> {
26+
try {
27+
const stagedFiles = this.getStagedFiles()
28+
if (stagedFiles.length === 0) {
29+
this.log('No staged files found')
30+
return true
31+
}
32+
33+
this.log(`Processing ${stagedFiles.length} staged files`)
34+
35+
// Store original staged content for comparison (only if auto-restage is enabled)
36+
const originalContent = this.autoRestage ? this.captureStagedContent(stagedFiles) : new Map()
37+
38+
let hasErrors = false
39+
40+
// Process each pattern in the config
41+
for (const [pattern, commands] of Object.entries(config)) {
42+
const matchingFiles = this.getMatchingFiles(stagedFiles, pattern)
43+
if (matchingFiles.length === 0) continue
44+
45+
this.log(`Processing pattern "${pattern}" for ${matchingFiles.length} files`)
46+
47+
const commandArray = Array.isArray(commands) ? commands : [commands]
48+
49+
for (const command of commandArray) {
50+
const success = await this.runLintCommand(command, matchingFiles)
51+
if (!success) {
52+
hasErrors = true
53+
}
54+
}
55+
}
56+
57+
// Handle auto-restaging if enabled and no errors
58+
if (this.autoRestage && !hasErrors) {
59+
const modifiedFiles = this.getModifiedFiles(originalContent)
60+
if (modifiedFiles.length > 0) {
61+
this.log(`Auto-restaging ${modifiedFiles.length} modified files: ${modifiedFiles.join(', ')}`)
62+
this.restageFiles(modifiedFiles)
63+
64+
// Validate that restaged files still pass lint
65+
const validationSuccess = await this.validateStagedFiles(config)
66+
if (!validationSuccess) {
67+
this.log('Validation failed after auto-restaging')
68+
return false
69+
}
70+
}
71+
} else if (!this.autoRestage) {
72+
// Check if files were modified but not restaged
73+
const modifiedFiles = this.getModifiedFiles(originalContent)
74+
if (modifiedFiles.length > 0) {
75+
console.warn(`⚠️ Lint modified ${modifiedFiles.length} files but auto-restaging is disabled.`)
76+
console.warn(` Modified files: ${modifiedFiles.join(', ')}`)
77+
console.warn(` You may need to manually stage these files and commit again.`)
78+
}
79+
}
80+
81+
return !hasErrors
82+
83+
} catch (error) {
84+
console.error(`Staged lint process failed: ${error}`)
85+
return false
86+
}
87+
}
88+
89+
private getStagedFiles(): string[] {
90+
try {
91+
const output = execSync('git diff --cached --name-only', {
92+
cwd: this.projectRoot,
93+
encoding: 'utf-8'
94+
})
95+
return output.trim().split('\n').filter(Boolean)
96+
} catch {
97+
return []
98+
}
99+
}
100+
101+
private captureStagedContent(files: string[]): Map<string, string> {
102+
const content = new Map<string, string>()
103+
104+
for (const file of files) {
105+
try {
106+
const stagedContent = execSync(`git show :${file}`, {
107+
cwd: this.projectRoot,
108+
encoding: 'utf-8'
109+
})
110+
content.set(file, stagedContent)
111+
} catch {
112+
try {
113+
const workingContent = fs.readFileSync(path.join(this.projectRoot, file), 'utf-8')
114+
content.set(file, workingContent)
115+
} catch {
116+
// Skip files that can't be read
117+
}
118+
}
119+
}
120+
121+
return content
122+
}
123+
124+
private getMatchingFiles(files: string[], pattern: string): string[] {
125+
const regexPattern = pattern
126+
.replace(/\./g, '\\.')
127+
.replace(/\*/g, '.*')
128+
.replace(/\?/g, '.')
129+
130+
const regex = new RegExp(`^${regexPattern}$`)
131+
return files.filter(file => regex.test(file))
132+
}
133+
134+
private async runLintCommand(command: string, files: string[]): Promise<boolean> {
135+
try {
136+
const finalCommand = command.includes('{files}')
137+
? command.replace('{files}', files.join(' '))
138+
: `${command} ${files.join(' ')}`
139+
140+
this.log(`Running: ${finalCommand}`)
141+
142+
execSync(finalCommand, {
143+
cwd: this.projectRoot,
144+
stdio: this.verbose ? 'inherit' : 'pipe'
145+
})
146+
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
157+
}
158+
}
159+
}
160+
161+
private getModifiedFiles(originalContent: Map<string, string>): string[] {
162+
const modifiedFiles: string[] = []
163+
164+
for (const [file, originalText] of originalContent) {
165+
try {
166+
const currentContent = fs.readFileSync(path.join(this.projectRoot, file), 'utf-8')
167+
if (currentContent !== originalText) {
168+
modifiedFiles.push(file)
169+
}
170+
} catch {
171+
// Skip files that can't be read
172+
}
173+
}
174+
175+
return modifiedFiles
176+
}
177+
178+
private restageFiles(files: string[]): void {
179+
if (files.length === 0) return
180+
181+
try {
182+
execSync(`git add ${files.join(' ')}`, {
183+
cwd: this.projectRoot,
184+
stdio: this.verbose ? 'inherit' : 'pipe'
185+
})
186+
} catch (error) {
187+
throw new Error(`Failed to re-stage files: ${error}`)
188+
}
189+
}
190+
191+
private async validateStagedFiles(config: StagedLintConfig): Promise<boolean> {
192+
const stagedFiles = this.getStagedFiles()
193+
194+
for (const [pattern, commands] of Object.entries(config)) {
195+
const matchingFiles = this.getMatchingFiles(stagedFiles, pattern)
196+
if (matchingFiles.length === 0) continue
197+
198+
const commandArray = Array.isArray(commands) ? commands : [commands]
199+
200+
for (const command of commandArray) {
201+
// Remove --fix flag for validation
202+
const validationCommand = command.replace(/--fix\b/g, '').trim()
203+
204+
const success = await this.runLintCommand(validationCommand, matchingFiles)
205+
if (!success) {
206+
return false
207+
}
208+
}
209+
}
210+
211+
return true
212+
}
213+
214+
private log(message: string): void {
215+
if (this.verbose) {
216+
console.warn(`[staged-lint] ${message}`)
217+
}
218+
}
219+
}
220+
221+
/**
222+
* Enhanced staged lint function with configurable auto-restaging
223+
*/
224+
export async function runEnhancedStagedLint(
225+
config: StagedLintConfig,
226+
projectRoot: string = process.cwd(),
227+
options: { verbose?: boolean; autoRestage?: boolean } = {}
228+
): Promise<boolean> {
229+
const { verbose = false, autoRestage = true } = options
230+
const processor = new StagedLintProcessor(projectRoot, verbose, autoRestage)
231+
return processor.process(config)
232+
}

src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export type GitHooksConfig = {
1515
'preserveUnused'?: boolean | typeof VALID_GIT_HOOKS[number][]
1616
'verbose'?: boolean
1717
'staged-lint'?: StagedLintConfig
18+
'autoRestage'?: boolean
1819
}
1920

2021
export interface SetHooksFromConfigOptions {

test/bun-git-hooks.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,14 @@ describe('bun-git-hooks', () => {
170170
path.join(testsFolder, 'project_without_configuration'),
171171
)
172172

173+
// AutoRestage configurations
174+
const PROJECT_WITH_AUTO_RESTAGE_ENABLED = path.normalize(
175+
path.join(testsFolder, 'project_with_auto_restage_enabled'),
176+
)
177+
const PROJECT_WITH_AUTO_RESTAGE_DISABLED = path.normalize(
178+
path.join(testsFolder, 'project_with_auto_restage_disabled'),
179+
)
180+
173181
// CLI verbose behavior
174182
describe('CLI verbose', () => {
175183
beforeEach(() => {
@@ -708,6 +716,68 @@ describe('bun-git-hooks', () => {
708716
})
709717
})
710718

719+
describe('autoRestage configuration tests', () => {
720+
describe('autoRestage enabled', () => {
721+
it('creates git hooks with autoRestage enabled configuration', () => {
722+
createGitHooksFolder(PROJECT_WITH_AUTO_RESTAGE_ENABLED)
723+
724+
const packageJsonPath = path.join(PROJECT_WITH_AUTO_RESTAGE_ENABLED, 'package.json')
725+
const packageJsonContent = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'))
726+
gitHooks.setHooksFromConfig(PROJECT_WITH_AUTO_RESTAGE_ENABLED, {
727+
configFile: packageJsonContent['git-hooks'],
728+
})
729+
730+
const installedHooks = getInstalledGitHooks(
731+
path.normalize(
732+
path.join(PROJECT_WITH_AUTO_RESTAGE_ENABLED, '.git', 'hooks'),
733+
),
734+
)
735+
736+
expect(installedHooks).toHaveProperty('pre-commit')
737+
expect(installedHooks['pre-commit']).toContain('bun git-hooks run-staged-lint pre-commit')
738+
})
739+
740+
it('validates autoRestage enabled configuration', () => {
741+
const packageJsonPath = path.join(PROJECT_WITH_AUTO_RESTAGE_ENABLED, 'package.json')
742+
const packageJsonContent = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'))
743+
const config = packageJsonContent['git-hooks']
744+
745+
expect(config['pre-commit']).toHaveProperty('autoRestage', true)
746+
expect(config['pre-commit']).toHaveProperty('staged-lint')
747+
})
748+
})
749+
750+
describe('autoRestage disabled', () => {
751+
it('creates git hooks with autoRestage disabled configuration', () => {
752+
createGitHooksFolder(PROJECT_WITH_AUTO_RESTAGE_DISABLED)
753+
754+
const packageJsonPath = path.join(PROJECT_WITH_AUTO_RESTAGE_DISABLED, 'package.json')
755+
const packageJsonContent = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'))
756+
gitHooks.setHooksFromConfig(PROJECT_WITH_AUTO_RESTAGE_DISABLED, {
757+
configFile: packageJsonContent['git-hooks'],
758+
})
759+
760+
const installedHooks = getInstalledGitHooks(
761+
path.normalize(
762+
path.join(PROJECT_WITH_AUTO_RESTAGE_DISABLED, '.git', 'hooks'),
763+
),
764+
)
765+
766+
expect(installedHooks).toHaveProperty('pre-commit')
767+
expect(installedHooks['pre-commit']).toContain('bun git-hooks run-staged-lint pre-commit')
768+
})
769+
770+
it('validates autoRestage disabled configuration', () => {
771+
const packageJsonPath = path.join(PROJECT_WITH_AUTO_RESTAGE_DISABLED, 'package.json')
772+
const packageJsonContent = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'))
773+
const config = packageJsonContent['git-hooks']
774+
775+
expect(config['pre-commit']).toHaveProperty('autoRestage', false)
776+
expect(config['pre-commit']).toHaveProperty('staged-lint')
777+
})
778+
})
779+
})
780+
711781
afterEach(() => {
712782
[
713783
PROJECT_WITH_CONF_IN_SEPARATE_JS_ALT,
@@ -722,6 +792,8 @@ describe('bun-git-hooks', () => {
722792
PROJECT_WITH_CONF_IN_PACKAGE_JSON,
723793
PROJECT_WITH_UNUSED_CONF_IN_PACKAGE_JSON,
724794
PROJECT_WITH_CUSTOM_CONF,
795+
PROJECT_WITH_AUTO_RESTAGE_ENABLED,
796+
PROJECT_WITH_AUTO_RESTAGE_DISABLED,
725797
].forEach((testCase) => {
726798
removeGitHooksFolder(testCase)
727799
})
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"name": "test-auto-restage-disabled",
3+
"version": "1.0.0",
4+
"devDependencies": {
5+
"bun-git-hooks": "^0.1.0"
6+
},
7+
"git-hooks": {
8+
"pre-commit": {
9+
"staged-lint": {
10+
"*.js": "echo 'linting' && exit 0"
11+
},
12+
"autoRestage": false
13+
}
14+
}
15+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"name": "test-auto-restage-enabled",
3+
"version": "1.0.0",
4+
"devDependencies": {
5+
"bun-git-hooks": "^0.1.0"
6+
},
7+
"git-hooks": {
8+
"pre-commit": {
9+
"staged-lint": {
10+
"*.js": "echo 'linting' && exit 0"
11+
},
12+
"autoRestage": true
13+
}
14+
}
15+
}

0 commit comments

Comments
 (0)